USB-Meter / USB Meter / Model / ConsumptionMonitorStore.swift
Newer Older
356 lines | 12.716kb
Bogdan Timofte authored a month ago
1
//
2
//  ConsumptionMonitorStore.swift
3
//  USB Meter
4
//
5

            
6
import Foundation
7
import Combine
8

            
9
// MARK: - Store
10

            
11
final class ConsumptionMonitorStore {
12
    private struct Snapshot: Codable {
13
        var sessions: [ConsumptionMonitorSessionSummary]
14
    }
15

            
16
    private enum Keys {
17
        static let cloudSessions = "ConsumptionMonitorStore.sessions"
18
    }
19

            
20
    private let fileManager: FileManager
21
    private let fileURL: URL
22
    private let encoder: JSONEncoder
23
    private let decoder: JSONDecoder
24
    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
25
    private let workQueue = DispatchQueue(label: "ConsumptionMonitorStore.Queue")
26
    private var ubiquitousObserver: NSObjectProtocol?
27
    private var ubiquityIdentityObserver: NSObjectProtocol?
28

            
29
    private var cachedSessions: [ConsumptionMonitorSessionSummary]?
30

            
31
    init(fileManager: FileManager = .default) {
32
        self.fileManager = fileManager
33

            
34
        let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
35
            ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
36
            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
37

            
38
        let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
39
        fileURL = directoryURL.appendingPathComponent("consumption-monitor.json", isDirectory: false)
40

            
41
        encoder = JSONEncoder()
42
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
43
        encoder.dateEncodingStrategy = .iso8601
44

            
45
        decoder = JSONDecoder()
46
        decoder.dateDecodingStrategy = .iso8601
47

            
48
        ubiquitousObserver = NotificationCenter.default.addObserver(
49
            forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
50
            object: ubiquitousStore,
51
            queue: nil
52
        ) { [weak self] notification in
53
            self?.handleUbiquitousStoreChange(notification)
54
        }
55

            
56
        ubiquityIdentityObserver = NotificationCenter.default.addObserver(
57
            forName: NSNotification.Name.NSUbiquityIdentityDidChange,
58
            object: nil,
59
            queue: nil
60
        ) { [weak self] _ in
61
            self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
62
        }
63

            
64
        ubiquitousStore.synchronize()
65
        syncLocalValuesToCloudIfPossible(reason: "startup")
66
    }
67

            
68
    func sessionsByDeviceID() -> [UUID: [ConsumptionMonitorSessionSummary]] {
69
        Dictionary(grouping: loadSessions()) { $0.chargedDeviceID }
70
            .mapValues { sessions in
71
                sessions.sorted { lhs, rhs in
72
                    (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
73
                }
74
            }
75
    }
76

            
77
    @discardableResult
78
    func save(_ session: ConsumptionMonitorSessionSummary) -> Bool {
79
        var sessions = loadSessions()
80
        if let index = sessions.firstIndex(where: { $0.id == session.id }) {
81
            sessions[index] = session
82
        } else {
83
            sessions.append(session)
84
        }
85
        sessions.sort { lhs, rhs in
86
            (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
87
        }
88
        return persist(sessions)
89
    }
90

            
91
    @discardableResult
92
    func appendSample(_ sample: ConsumptionMonitorSample, to sessionID: UUID) -> Bool {
93
        var sessions = loadSessions()
94
        guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else {
95
            return false
96
        }
97
        sessions[index].samples.append(sample)
98
        return persist(sessions)
99
    }
100

            
101
    @discardableResult
102
    func completeSession(id sessionID: UUID, endedAt: Date) -> Bool {
103
        var sessions = loadSessions()
104
        guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else {
105
            return false
106
        }
107
        sessions[index].endedAt = endedAt
108
        sessions.sort { lhs, rhs in
109
            (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
110
        }
111
        return persist(sessions)
112
    }
113

            
114
    @discardableResult
115
    func removeSession(id: UUID, deviceID: UUID) -> Bool {
116
        let previous = loadSessions()
117
        let filtered = previous.filter { !($0.id == id && $0.chargedDeviceID == deviceID) }
118
        guard filtered.count != previous.count else { return true }
119
        return persist(filtered)
120
    }
121

            
122
    @discardableResult
123
    func removeSessions(for deviceID: UUID) -> Bool {
124
        let previous = loadSessions()
125
        let filtered = previous.filter { $0.chargedDeviceID != deviceID }
126
        guard filtered.count != previous.count else { return true }
127
        return persist(filtered)
128
    }
129

            
130
    func openSession(for meterMACAddress: String) -> ConsumptionMonitorSessionSummary? {
131
        loadSessions().first { $0.isOpen && $0.meterMACAddress == meterMACAddress }
132
    }
133

            
134
    // MARK: - Private
135

            
136
    private func loadSessions() -> [ConsumptionMonitorSessionSummary] {
137
        if let cachedSessions { return cachedSessions }
138
        let local = loadLocalSessions()
139
        let cloud = loadCloudSessions()
140
        let merged = merge(localSessions: local, cloudSessions: cloud)
141
        cachedSessions = merged
142
        return merged
143
    }
144

            
145
    private func loadLocalSessions() -> [ConsumptionMonitorSessionSummary] {
146
        guard fileManager.fileExists(atPath: fileURL.path) else { return [] }
147
        do {
148
            let data = try Data(contentsOf: fileURL)
149
            return try decoder.decode(Snapshot.self, from: data).sessions
150
        } catch {
151
            track("ConsumptionMonitorStore: failed to load local sessions: \(error.localizedDescription)")
152
            return []
153
        }
154
    }
155

            
156
    private func loadCloudSessions() -> [ConsumptionMonitorSessionSummary] {
157
        guard isICloudDriveAvailable,
158
              let data = ubiquitousStore.data(forKey: Keys.cloudSessions) else { return [] }
159
        do {
160
            return try decoder.decode(Snapshot.self, from: data).sessions
161
        } catch {
162
            track("ConsumptionMonitorStore: failed to decode cloud sessions: \(error.localizedDescription)")
163
            return []
164
        }
165
    }
166

            
167
    @discardableResult
168
    private func persist(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
169
        let didLocal = persistLocally(sessions)
170
        let didCloud = persistToCloudIfPossible(sessions)
171
        if didLocal || didCloud {
172
            cachedSessions = sessions
173
        }
174
        return didLocal || didCloud
175
    }
176

            
177
    @discardableResult
178
    private func persistLocally(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
179
        do {
180
            try fileManager.createDirectory(
181
                at: fileURL.deletingLastPathComponent(),
182
                withIntermediateDirectories: true,
183
                attributes: nil
184
            )
185
            let data = try encoder.encode(Snapshot(sessions: sessions))
186
            try data.write(to: fileURL, options: .atomic)
187
            return true
188
        } catch {
189
            track("ConsumptionMonitorStore: failed to save locally: \(error.localizedDescription)")
190
            return false
191
        }
192
    }
193

            
194
    @discardableResult
195
    private func persistToCloudIfPossible(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
196
        guard isICloudDriveAvailable else { return false }
197
        do {
198
            let data = try encoder.encode(Snapshot(sessions: sessions))
199
            ubiquitousStore.set(data, forKey: Keys.cloudSessions)
200
            return ubiquitousStore.synchronize()
201
        } catch {
202
            track("ConsumptionMonitorStore: failed to sync to cloud: \(error.localizedDescription)")
203
            return false
204
        }
205
    }
206

            
207
    private func merge(
208
        localSessions: [ConsumptionMonitorSessionSummary],
209
        cloudSessions: [ConsumptionMonitorSessionSummary]
210
    ) -> [ConsumptionMonitorSessionSummary] {
211
        var byID: [UUID: ConsumptionMonitorSessionSummary] = [:]
212
        for session in localSessions { byID[session.id] = session }
213
        for session in cloudSessions {
214
            if let existing = byID[session.id] {
215
                // Keep the one with more samples or a definitive end time
216
                if session.samples.count > existing.samples.count || (session.endedAt != nil && existing.endedAt == nil) {
217
                    byID[session.id] = session
218
                }
219
            } else {
220
                byID[session.id] = session
221
            }
222
        }
223
        return byID.values.sorted { lhs, rhs in
224
            (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture)
225
        }
226
    }
227

            
228
    private func syncLocalValuesToCloudIfPossible(reason: String) {
229
        let sessions = loadLocalSessions()
230
        guard !sessions.isEmpty else { return }
231
        persistToCloudIfPossible(sessions)
232
    }
233

            
234
    private func handleUbiquitousStoreChange(_ notification: Notification) {
235
        cachedSessions = nil
236
        NotificationCenter.default.post(name: .consumptionMonitorStoreDidChange, object: nil)
237
    }
238

            
239
    private var isICloudDriveAvailable: Bool {
240
        FileManager.default.ubiquityIdentityToken != nil
241
    }
242
}
243

            
244
extension Notification.Name {
245
    static let consumptionMonitorStoreDidChange = Notification.Name("ConsumptionMonitorStore.DidChange")
246
}
247

            
248
// MARK: - Live Session
249

            
250
final class ConsumptionMonitorLiveSession: ObservableObject {
251
    static let bucketDurationSeconds: TimeInterval = 60
252

            
253
    let sessionID: UUID
254
    let chargedDeviceID: UUID
255
    let meterMACAddress: String
256
    let startedAt: Date
257

            
258
    @Published private(set) var currentPowerWatts: Double = 0
259
    @Published private(set) var currentCurrentAmps: Double = 0
260
    @Published private(set) var currentVoltageVolts: Double = 0
261
    @Published private(set) var committedSampleCount: Int = 0
262
    @Published private(set) var committedSamples: [ConsumptionMonitorSample] = []
263
    @Published private(set) var cumulativeEnergyWh: Double = 0
264
    @Published private(set) var isRunning: Bool = false
265

            
266
    var meterName: String?
267
    var meterModel: String?
268
    var onSample: ((ConsumptionMonitorSample) -> Void)?
269
    var onChange: (() -> Void)?
270

            
271
    private var flushTimer: Timer?
272
    private var powerReadings: [(power: Double, current: Double, voltage: Double)] = []
273
    private var lastObservationTime: Date?
274
    private var nextBucketIndex: Int = 0
275

            
276
    init(sessionID: UUID, chargedDeviceID: UUID, meterMACAddress: String, startedAt: Date) {
277
        self.sessionID = sessionID
278
        self.chargedDeviceID = chargedDeviceID
279
        self.meterMACAddress = meterMACAddress
280
        self.startedAt = startedAt
281
    }
282

            
283
    func start() {
284
        guard !isRunning else { return }
285
        isRunning = true
286
        scheduleNextFlush()
287
    }
288

            
289
    func stop() {
290
        isRunning = false
291
        flushTimer?.invalidate()
292
        flushTimer = nil
293
        flushBucket()
294
    }
295

            
296
    func observe(powerWatts: Double, currentAmps: Double, voltageVolts: Double, observedAt: Date) {
297
        currentPowerWatts = powerWatts
298
        currentCurrentAmps = currentAmps
299
        currentVoltageVolts = voltageVolts
300

            
301
        if let last = lastObservationTime {
302
            let dtHours = observedAt.timeIntervalSince(last) / 3600
303
            cumulativeEnergyWh += powerWatts * dtHours
304
        }
305
        lastObservationTime = observedAt
306

            
307
        powerReadings.append((powerWatts, currentAmps, voltageVolts))
308
        onChange?()
309
    }
310

            
311
    var elapsedDuration: TimeInterval {
312
        Date().timeIntervalSince(startedAt)
313
    }
314

            
315
    // MARK: - Private
316

            
317
    private func scheduleNextFlush() {
318
        flushTimer?.invalidate()
319
        flushTimer = Timer.scheduledTimer(
320
            withTimeInterval: Self.bucketDurationSeconds,
321
            repeats: false
322
        ) { [weak self] _ in
323
            self?.flushBucket()
324
            if self?.isRunning == true {
325
                self?.scheduleNextFlush()
326
            }
327
        }
328
    }
329

            
330
    private func flushBucket() {
331
        guard !powerReadings.isEmpty else { return }
332

            
333
        let n = Double(powerReadings.count)
334
        let avgPower = powerReadings.map(\.power).reduce(0, +) / n
335
        let avgCurrent = powerReadings.map(\.current).reduce(0, +) / n
336
        let avgVoltage = powerReadings.map(\.voltage).reduce(0, +) / n
337
        let bucketIndex = nextBucketIndex
338
        nextBucketIndex += 1
339

            
340
        let sample = ConsumptionMonitorSample(
341
            bucketIndex: bucketIndex,
342
            timestamp: Date(),
343
            averagePowerWatts: avgPower,
344
            averageCurrentAmps: avgCurrent,
345
            averageVoltageVolts: avgVoltage,
346
            sampleCount: powerReadings.count,
347
            cumulativeEnergyWh: cumulativeEnergyWh
348
        )
349

            
350
        powerReadings = []
351
        committedSamples.append(sample)
352
        committedSampleCount = nextBucketIndex
353
        onSample?(sample)
354
        onChange?()
355
    }
356
}