USB-Meter / USB Meter / Model / ChargerStandbyPowerStore.swift
Newer Older
460 lines | 15.534kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargerStandbyPowerStore.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 13/04/2026.
6
//
7

            
8
import Foundation
9

            
10
final class ChargerStandbyPowerStore {
11
    private struct Snapshot: Codable {
12
        var measurements: [ChargerStandbyPowerMeasurementSummary]
13
    }
14

            
Bogdan Timofte authored a month ago
15
    private enum Keys {
16
        static let cloudMeasurements = "ChargerStandbyPowerStore.measurements"
17
    }
18

            
Bogdan Timofte authored a month ago
19
    private let fileManager: FileManager
20
    private let fileURL: URL
21
    private let encoder: JSONEncoder
22
    private let decoder: JSONDecoder
Bogdan Timofte authored a month ago
23
    private let ubiquitousStore = NSUbiquitousKeyValueStore.default
24
    private let workQueue = DispatchQueue(label: "ChargerStandbyPowerStore.Queue")
25
    private var ubiquitousObserver: NSObjectProtocol?
26
    private var ubiquityIdentityObserver: NSObjectProtocol?
Bogdan Timofte authored a month ago
27

            
28
    private var cachedMeasurements: [ChargerStandbyPowerMeasurementSummary]?
29

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

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

            
37
        let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
38
        fileURL = directoryURL.appendingPathComponent("charger-standby-power.json", isDirectory: false)
39

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

            
44
        decoder = JSONDecoder()
45
        decoder.dateDecodingStrategy = .iso8601
Bogdan Timofte authored a month ago
46

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

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

            
63
        ubiquitousStore.synchronize()
64
        syncLocalValuesToCloudIfPossible(reason: "startup")
Bogdan Timofte authored a month ago
65
    }
66

            
67
    func measurementsByChargerID() -> [UUID: [ChargerStandbyPowerMeasurementSummary]] {
68
        Dictionary(grouping: loadMeasurements()) { $0.chargerID }
69
            .mapValues { measurements in
70
                measurements.sorted { lhs, rhs in
71
                    if lhs.endedAt != rhs.endedAt {
72
                        return lhs.endedAt > rhs.endedAt
73
                    }
74
                    return lhs.id.uuidString > rhs.id.uuidString
75
                }
76
            }
77
    }
78

            
79
    @discardableResult
80
    func save(_ measurement: ChargerStandbyPowerMeasurementSummary) -> Bool {
81
        var measurements = loadMeasurements()
82
        measurements.append(measurement)
83
        measurements.sort { lhs, rhs in
84
            if lhs.endedAt != rhs.endedAt {
85
                return lhs.endedAt > rhs.endedAt
86
            }
87
            return lhs.id.uuidString > rhs.id.uuidString
88
        }
89

            
90
        return persist(measurements)
91
    }
92

            
93
    @discardableResult
94
    func removeMeasurements(for chargerID: UUID) -> Bool {
95
        let previousMeasurements = loadMeasurements()
96
        let filteredMeasurements = previousMeasurements.filter { $0.chargerID != chargerID }
97
        guard filteredMeasurements.count != previousMeasurements.count else {
98
            return true
99
        }
100

            
101
        return persist(filteredMeasurements)
102
    }
103

            
Bogdan Timofte authored a month ago
104
    @discardableResult
105
    func removeMeasurement(id: UUID, chargerID: UUID? = nil) -> Bool {
106
        let previousMeasurements = loadMeasurements()
107
        let filteredMeasurements = previousMeasurements.filter { measurement in
108
            guard measurement.id == id else {
109
                return true
110
            }
111

            
112
            if let chargerID {
113
                return measurement.chargerID != chargerID
114
            }
115

            
116
            return false
117
        }
118

            
119
        guard filteredMeasurements.count != previousMeasurements.count else {
120
            return true
121
        }
122

            
123
        return persist(filteredMeasurements)
124
    }
125

            
Bogdan Timofte authored a month ago
126
    private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
127
        if let cachedMeasurements {
128
            return cachedMeasurements
129
        }
130

            
Bogdan Timofte authored a month ago
131
        let localMeasurements = loadLocalMeasurements()
132
        let cloudMeasurements = loadCloudMeasurements()
133
        let mergedMeasurements = merge(localMeasurements: localMeasurements, cloudMeasurements: cloudMeasurements)
134

            
135
        cachedMeasurements = mergedMeasurements
136
        return mergedMeasurements
137
    }
138

            
139
    private func loadLocalMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
Bogdan Timofte authored a month ago
140
        guard fileManager.fileExists(atPath: fileURL.path) else {
141
            return []
142
        }
143
        do {
144
            let data = try Data(contentsOf: fileURL)
145
            let snapshot = try decoder.decode(Snapshot.self, from: data)
146
            return snapshot.measurements
147
        } catch {
148
            track("Failed to load charger standby power history: \(error.localizedDescription)")
Bogdan Timofte authored a month ago
149
            return []
150
        }
151
    }
152

            
153
    private func loadCloudMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
154
        guard isICloudDriveAvailable,
155
              let data = ubiquitousStore.data(forKey: Keys.cloudMeasurements) else {
156
            return []
157
        }
158

            
159
        do {
160
            let snapshot = try decoder.decode(Snapshot.self, from: data)
161
            return snapshot.measurements
162
        } catch {
163
            track("Failed to decode charger standby power history from iCloud KVS: \(error.localizedDescription)")
Bogdan Timofte authored a month ago
164
            return []
165
        }
166
    }
167

            
168
    @discardableResult
169
    private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
Bogdan Timofte authored a month ago
170
        let sortedMeasurements = sortMeasurements(measurements)
171
        let didPersistLocal = persistLocally(sortedMeasurements)
172
        let didPersistCloud = persistToCloudIfPossible(sortedMeasurements)
173

            
174
        if didPersistLocal || didPersistCloud {
175
            cachedMeasurements = sortedMeasurements
176
        }
177

            
178
        return didPersistLocal || didPersistCloud
179
    }
180

            
181
    @discardableResult
182
    private func persistLocally(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
Bogdan Timofte authored a month ago
183
        do {
184
            try fileManager.createDirectory(
185
                at: fileURL.deletingLastPathComponent(),
186
                withIntermediateDirectories: true,
187
                attributes: nil
188
            )
189
            let snapshot = Snapshot(measurements: measurements)
190
            let data = try encoder.encode(snapshot)
191
            try data.write(to: fileURL, options: .atomic)
192
            return true
193
        } catch {
194
            track("Failed to save charger standby power history: \(error.localizedDescription)")
195
            return false
196
        }
197
    }
Bogdan Timofte authored a month ago
198

            
199
    @discardableResult
200
    private func persistToCloudIfPossible(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
201
        guard isICloudDriveAvailable else {
202
            return false
203
        }
204

            
205
        do {
206
            let snapshot = Snapshot(measurements: measurements)
207
            let data = try encoder.encode(snapshot)
208
            ubiquitousStore.set(data, forKey: Keys.cloudMeasurements)
209
            ubiquitousStore.synchronize()
210
            return true
211
        } catch {
212
            track("Failed to encode charger standby power history for iCloud KVS: \(error.localizedDescription)")
213
            return false
214
        }
215
    }
216

            
217
    private func merge(
218
        localMeasurements: [ChargerStandbyPowerMeasurementSummary],
219
        cloudMeasurements: [ChargerStandbyPowerMeasurementSummary]
220
    ) -> [ChargerStandbyPowerMeasurementSummary] {
221
        var mergedByID: [UUID: ChargerStandbyPowerMeasurementSummary] = [:]
222

            
223
        for measurement in localMeasurements {
224
            mergedByID[measurement.id] = measurement
225
        }
226

            
227
        for measurement in cloudMeasurements {
228
            mergedByID[measurement.id] = measurement
229
        }
230

            
231
        return sortMeasurements(Array(mergedByID.values))
232
    }
233

            
234
    private func sortMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> [ChargerStandbyPowerMeasurementSummary] {
235
        measurements.sorted { lhs, rhs in
236
            if lhs.endedAt != rhs.endedAt {
237
                return lhs.endedAt > rhs.endedAt
238
            }
239
            return lhs.id.uuidString > rhs.id.uuidString
240
        }
241
    }
242

            
243
    private var isICloudDriveAvailable: Bool {
244
        FileManager.default.ubiquityIdentityToken != nil
245
    }
246

            
247
    private func handleUbiquitousStoreChange(_ notification: Notification) {
248
        if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String],
249
           changedKeys.contains(Keys.cloudMeasurements) == false {
250
            return
251
        }
252

            
253
        workQueue.async { [weak self] in
254
            guard let self else { return }
255
            let mergedMeasurements = self.merge(
256
                localMeasurements: self.loadLocalMeasurements(),
257
                cloudMeasurements: self.loadCloudMeasurements()
258
            )
259
            self.cachedMeasurements = mergedMeasurements
260
            _ = self.persistLocally(mergedMeasurements)
261
            DispatchQueue.main.async {
262
                NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil)
263
            }
264
        }
265
    }
266

            
267
    private func syncLocalValuesToCloudIfPossible(reason: String) {
268
        guard isICloudDriveAvailable else {
269
            return
270
        }
271

            
272
        workQueue.async { [weak self] in
273
            guard let self else { return }
274
            let mergedMeasurements = self.merge(
275
                localMeasurements: self.loadLocalMeasurements(),
276
                cloudMeasurements: self.loadCloudMeasurements()
277
            )
278
            let didPersistLocal = self.persistLocally(mergedMeasurements)
279
            let didPersistCloud = self.persistToCloudIfPossible(mergedMeasurements)
280
            self.cachedMeasurements = mergedMeasurements
281

            
282
            if didPersistLocal || didPersistCloud {
283
                track("ChargerStandbyPowerStore synchronized standby measurements with iCloud KVS (\(reason)).")
284
                DispatchQueue.main.async {
285
                    NotificationCenter.default.post(name: .chargerStandbyPowerStoreDidChange, object: nil)
286
                }
287
            }
288
        }
289
    }
290

            
291
    deinit {
292
        if let observer = ubiquitousObserver {
293
            NotificationCenter.default.removeObserver(observer)
294
        }
295
        if let observer = ubiquityIdentityObserver {
296
            NotificationCenter.default.removeObserver(observer)
297
        }
298
    }
Bogdan Timofte authored a month ago
299
}
300

            
301
final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable {
302
    let id = UUID()
303
    let chargerID: UUID
304
    let meterMACAddress: String
305
    let meterName: String
306
    let meterModel: String
307
    let startedAt: Date
308

            
309
    @Published private(set) var samples: [ChargerStandbyPowerSample] = []
310
    @Published private(set) var statistics: ChargerStandbyPowerMeasurementStatistics?
311
    @Published private(set) var stabilizedAt: Date?
312
    @Published private(set) var lastObservedAt: Date?
313
    @Published private(set) var isRunning = false
314

            
315
    var onChange: (() -> Void)?
316
    var onStabilized: (() -> Void)?
317

            
318
    private weak var meter: Meter?
319
    private var timer: Timer?
320
    private var hasTriggeredStabilityCallback = false
321
    private let sampleInterval: TimeInterval = 1
322

            
323
    init(chargerID: UUID, meter: Meter, startedAt: Date = Date()) {
324
        self.chargerID = chargerID
325
        meterMACAddress = meter.btSerial.macAddress.description
326
        meterName = meter.name
327
        meterModel = meter.deviceModelSummary
328
        self.startedAt = startedAt
329
        self.meter = meter
330
    }
331

            
332
    deinit {
333
        stop()
334
    }
335

            
336
    var sampleCount: Int {
337
        statistics?.sampleCount ?? samples.count
338
    }
339

            
340
    var hasSamples: Bool {
341
        sampleCount > 0
342
    }
343

            
344
    var readinessDescription: String {
345
        guard let statistics else {
346
            if let meter {
347
                switch meter.operationalState {
348
                case .peripheralConnectionPending, .peripheralConnected, .peripheralReady, .comunicating:
349
                    return "Connecting to meter"
350
                case .peripheralNotConnected:
351
                    return "Starting meter connection"
352
                case .notPresent, .dataIsAvailable:
353
                    break
354
                }
355
            }
356

            
357
            return "Waiting for live samples"
358
        }
359

            
360
        if statistics.isStable {
361
            return "Stable average reached"
362
        }
363

            
364
        return "Collecting baseline"
365
    }
366

            
367
    func start() {
368
        guard isRunning == false else {
369
            return
370
        }
371

            
372
        isRunning = true
373
        captureSampleIfPossible(at: Date())
374

            
375
        let timer = Timer(timeInterval: sampleInterval, repeats: true) { [weak self] _ in
376
            self?.captureSampleIfPossible(at: Date())
377
        }
378
        self.timer = timer
379
        RunLoop.main.add(timer, forMode: .common)
380
        onChange?()
381
    }
382

            
383
    func stop() {
384
        timer?.invalidate()
385
        timer = nil
386
        guard isRunning else {
387
            return
388
        }
389
        isRunning = false
390
        onChange?()
391
    }
392

            
393
    func makeSummary(endedAt: Date = Date()) -> ChargerStandbyPowerMeasurementSummary? {
394
        ChargerStandbyPowerMeasurementAnalyzer.measurementSummary(
395
            chargerID: chargerID,
396
            meterMACAddress: meterMACAddress,
397
            meterName: meterName,
398
            meterModel: meterModel,
399
            startedAt: startedAt,
400
            endedAt: endedAt,
401
            samples: samples,
402
            stabilizedAt: stabilizedAt
403
        )
404
    }
405

            
406
    private func captureSampleIfPossible(at timestamp: Date) {
407
        defer {
408
            statistics = ChargerStandbyPowerMeasurementAnalyzer.statistics(
409
                from: samples,
410
                startedAt: startedAt,
411
                referenceDate: timestamp
412
            )
413
            onChange?()
414
        }
415

            
416
        guard let meter else {
417
            return
418
        }
419

            
420
        guard meter.operationalState == .dataIsAvailable else {
421
            return
422
        }
423

            
424
        let powerWatts = meter.power
425
        let currentAmps = meter.current
426
        let voltageVolts = meter.voltage
427

            
428
        guard powerWatts.isFinite, currentAmps.isFinite, voltageVolts.isFinite else {
429
            return
430
        }
431

            
432
        lastObservedAt = timestamp
433
        samples.append(
434
            ChargerStandbyPowerSample(
435
                timestamp: timestamp,
436
                powerWatts: powerWatts,
437
                currentAmps: currentAmps,
438
                voltageVolts: voltageVolts
439
            )
440
        )
441

            
442
        if stabilizedAt == nil,
443
           let refreshedStatistics = ChargerStandbyPowerMeasurementAnalyzer.statistics(
444
               from: samples,
445
               startedAt: startedAt,
446
               referenceDate: timestamp
447
           ),
448
           refreshedStatistics.isStable {
449
            stabilizedAt = timestamp
450
            if hasTriggeredStabilityCallback == false {
451
                hasTriggeredStabilityCallback = true
452
                onStabilized?()
453
            }
454
        }
455
    }
456
}
Bogdan Timofte authored a month ago
457

            
458
extension Notification.Name {
459
    static let chargerStandbyPowerStoreDidChange = Notification.Name("ChargerStandbyPowerStoreDidChange")
460
}