USB-Meter / USB Meter / Model / ChargerStandbyPowerStore.swift
Newer Older
274 lines | 8.598kb
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

            
15
    private let fileManager: FileManager
16
    private let fileURL: URL
17
    private let encoder: JSONEncoder
18
    private let decoder: JSONDecoder
19

            
20
    private var cachedMeasurements: [ChargerStandbyPowerMeasurementSummary]?
21

            
22
    init(fileManager: FileManager = .default) {
23
        self.fileManager = fileManager
24

            
25
        let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
26
            ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
27
            ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
28

            
29
        let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
30
        fileURL = directoryURL.appendingPathComponent("charger-standby-power.json", isDirectory: false)
31

            
32
        encoder = JSONEncoder()
33
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
34
        encoder.dateEncodingStrategy = .iso8601
35

            
36
        decoder = JSONDecoder()
37
        decoder.dateDecodingStrategy = .iso8601
38
    }
39

            
40
    func measurementsByChargerID() -> [UUID: [ChargerStandbyPowerMeasurementSummary]] {
41
        Dictionary(grouping: loadMeasurements()) { $0.chargerID }
42
            .mapValues { measurements in
43
                measurements.sorted { lhs, rhs in
44
                    if lhs.endedAt != rhs.endedAt {
45
                        return lhs.endedAt > rhs.endedAt
46
                    }
47
                    return lhs.id.uuidString > rhs.id.uuidString
48
                }
49
            }
50
    }
51

            
52
    @discardableResult
53
    func save(_ measurement: ChargerStandbyPowerMeasurementSummary) -> Bool {
54
        var measurements = loadMeasurements()
55
        measurements.append(measurement)
56
        measurements.sort { lhs, rhs in
57
            if lhs.endedAt != rhs.endedAt {
58
                return lhs.endedAt > rhs.endedAt
59
            }
60
            return lhs.id.uuidString > rhs.id.uuidString
61
        }
62

            
63
        return persist(measurements)
64
    }
65

            
66
    @discardableResult
67
    func removeMeasurements(for chargerID: UUID) -> Bool {
68
        let previousMeasurements = loadMeasurements()
69
        let filteredMeasurements = previousMeasurements.filter { $0.chargerID != chargerID }
70
        guard filteredMeasurements.count != previousMeasurements.count else {
71
            return true
72
        }
73

            
74
        return persist(filteredMeasurements)
75
    }
76

            
77
    private func loadMeasurements() -> [ChargerStandbyPowerMeasurementSummary] {
78
        if let cachedMeasurements {
79
            return cachedMeasurements
80
        }
81

            
82
        guard fileManager.fileExists(atPath: fileURL.path) else {
83
            cachedMeasurements = []
84
            return []
85
        }
86

            
87
        do {
88
            let data = try Data(contentsOf: fileURL)
89
            let snapshot = try decoder.decode(Snapshot.self, from: data)
90
            cachedMeasurements = snapshot.measurements
91
            return snapshot.measurements
92
        } catch {
93
            track("Failed to load charger standby power history: \(error.localizedDescription)")
94
            cachedMeasurements = []
95
            return []
96
        }
97
    }
98

            
99
    @discardableResult
100
    private func persist(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> Bool {
101
        do {
102
            try fileManager.createDirectory(
103
                at: fileURL.deletingLastPathComponent(),
104
                withIntermediateDirectories: true,
105
                attributes: nil
106
            )
107
            let snapshot = Snapshot(measurements: measurements)
108
            let data = try encoder.encode(snapshot)
109
            try data.write(to: fileURL, options: .atomic)
110
            cachedMeasurements = measurements
111
            return true
112
        } catch {
113
            track("Failed to save charger standby power history: \(error.localizedDescription)")
114
            return false
115
        }
116
    }
117
}
118

            
119
final class ChargerStandbyPowerMonitorSession: ObservableObject, Identifiable {
120
    let id = UUID()
121
    let chargerID: UUID
122
    let meterMACAddress: String
123
    let meterName: String
124
    let meterModel: String
125
    let startedAt: Date
126

            
127
    @Published private(set) var samples: [ChargerStandbyPowerSample] = []
128
    @Published private(set) var statistics: ChargerStandbyPowerMeasurementStatistics?
129
    @Published private(set) var stabilizedAt: Date?
130
    @Published private(set) var lastObservedAt: Date?
131
    @Published private(set) var isRunning = false
132

            
133
    var onChange: (() -> Void)?
134
    var onStabilized: (() -> Void)?
135

            
136
    private weak var meter: Meter?
137
    private var timer: Timer?
138
    private var hasTriggeredStabilityCallback = false
139
    private let sampleInterval: TimeInterval = 1
140

            
141
    init(chargerID: UUID, meter: Meter, startedAt: Date = Date()) {
142
        self.chargerID = chargerID
143
        meterMACAddress = meter.btSerial.macAddress.description
144
        meterName = meter.name
145
        meterModel = meter.deviceModelSummary
146
        self.startedAt = startedAt
147
        self.meter = meter
148
    }
149

            
150
    deinit {
151
        stop()
152
    }
153

            
154
    var sampleCount: Int {
155
        statistics?.sampleCount ?? samples.count
156
    }
157

            
158
    var hasSamples: Bool {
159
        sampleCount > 0
160
    }
161

            
162
    var readinessDescription: String {
163
        guard let statistics else {
164
            if let meter {
165
                switch meter.operationalState {
166
                case .peripheralConnectionPending, .peripheralConnected, .peripheralReady, .comunicating:
167
                    return "Connecting to meter"
168
                case .peripheralNotConnected:
169
                    return "Starting meter connection"
170
                case .notPresent, .dataIsAvailable:
171
                    break
172
                }
173
            }
174

            
175
            return "Waiting for live samples"
176
        }
177

            
178
        if statistics.isStable {
179
            return "Stable average reached"
180
        }
181

            
182
        return "Collecting baseline"
183
    }
184

            
185
    func start() {
186
        guard isRunning == false else {
187
            return
188
        }
189

            
190
        isRunning = true
191
        captureSampleIfPossible(at: Date())
192

            
193
        let timer = Timer(timeInterval: sampleInterval, repeats: true) { [weak self] _ in
194
            self?.captureSampleIfPossible(at: Date())
195
        }
196
        self.timer = timer
197
        RunLoop.main.add(timer, forMode: .common)
198
        onChange?()
199
    }
200

            
201
    func stop() {
202
        timer?.invalidate()
203
        timer = nil
204
        guard isRunning else {
205
            return
206
        }
207
        isRunning = false
208
        onChange?()
209
    }
210

            
211
    func makeSummary(endedAt: Date = Date()) -> ChargerStandbyPowerMeasurementSummary? {
212
        ChargerStandbyPowerMeasurementAnalyzer.measurementSummary(
213
            chargerID: chargerID,
214
            meterMACAddress: meterMACAddress,
215
            meterName: meterName,
216
            meterModel: meterModel,
217
            startedAt: startedAt,
218
            endedAt: endedAt,
219
            samples: samples,
220
            stabilizedAt: stabilizedAt
221
        )
222
    }
223

            
224
    private func captureSampleIfPossible(at timestamp: Date) {
225
        defer {
226
            statistics = ChargerStandbyPowerMeasurementAnalyzer.statistics(
227
                from: samples,
228
                startedAt: startedAt,
229
                referenceDate: timestamp
230
            )
231
            onChange?()
232
        }
233

            
234
        guard let meter else {
235
            return
236
        }
237

            
238
        guard meter.operationalState == .dataIsAvailable else {
239
            return
240
        }
241

            
242
        let powerWatts = meter.power
243
        let currentAmps = meter.current
244
        let voltageVolts = meter.voltage
245

            
246
        guard powerWatts.isFinite, currentAmps.isFinite, voltageVolts.isFinite else {
247
            return
248
        }
249

            
250
        lastObservedAt = timestamp
251
        samples.append(
252
            ChargerStandbyPowerSample(
253
                timestamp: timestamp,
254
                powerWatts: powerWatts,
255
                currentAmps: currentAmps,
256
                voltageVolts: voltageVolts
257
            )
258
        )
259

            
260
        if stabilizedAt == nil,
261
           let refreshedStatistics = ChargerStandbyPowerMeasurementAnalyzer.statistics(
262
               from: samples,
263
               startedAt: startedAt,
264
               referenceDate: timestamp
265
           ),
266
           refreshedStatistics.isStable {
267
            stabilizedAt = timestamp
268
            if hasTriggeredStabilityCallback == false {
269
                hasTriggeredStabilityCallback = true
270
                onStabilized?()
271
            }
272
        }
273
    }
274
}