USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
412 lines | 11.888kb
Bogdan Timofte authored a month ago
1
//
2
//  ChargeInsightsModel.swift
3
//  USB Meter
4
//
5
//  Created by Codex on 10/04/2026.
6
//
7

            
8
import Foundation
9

            
10
enum ChargedDeviceClass: String, CaseIterable, Identifiable {
11
    case iphone
12
    case watch
13
    case powerbank
14
    case charger
15
    case other
16

            
17
    var id: String { rawValue }
18

            
19
    var title: String {
20
        switch self {
21
        case .iphone:
22
            return "iPhone"
23
        case .watch:
24
            return "Watch"
25
        case .powerbank:
26
            return "Powerbank"
27
        case .charger:
28
            return "Charger"
29
        case .other:
30
            return "Other"
31
        }
32
    }
33

            
34
    var symbolName: String {
35
        switch self {
36
        case .iphone:
37
            return "iphone"
38
        case .watch:
39
            return "applewatch"
40
        case .powerbank:
41
            return "battery.100.bolt"
42
        case .charger:
43
            return "bolt.badge.clock"
44
        case .other:
45
            return "shippingbox"
46
        }
47
    }
48
}
49

            
50
enum ChargeSessionStatus: String {
51
    case active
52
    case completed
53
    case abandoned
54

            
55
    var title: String {
56
        rawValue.capitalized
57
    }
58
}
59

            
60
enum ChargeSessionSourceMode: String {
61
    case live
62
    case offline
63
    case blended
64

            
65
    var title: String {
66
        switch self {
67
        case .live:
68
            return "Live"
69
        case .offline:
70
            return "Offline Counters"
71
        case .blended:
72
            return "Blended"
73
        }
74
    }
75
}
76

            
77
enum ChargingTransportMode: String, CaseIterable, Identifiable {
78
    case wired
79
    case wireless
80

            
81
    var id: String { rawValue }
82

            
83
    var title: String {
84
        switch self {
85
        case .wired:
86
            return "Wired"
87
        case .wireless:
88
            return "Wireless"
89
        }
90
    }
91

            
92
    var symbolName: String {
93
        switch self {
94
        case .wired:
95
            return "cable.connector"
96
        case .wireless:
97
            return "dot.radiowaves.left.and.right"
98
        }
99
    }
100
}
101

            
102
enum WirelessChargingProfile: String, CaseIterable, Identifiable {
103
    case magsafe
104
    case genericQi
105

            
106
    var id: String { rawValue }
107

            
108
    var title: String {
109
        switch self {
110
        case .magsafe:
111
            return "MagSafe"
112
        case .genericQi:
113
            return "Generic Qi"
114
        }
115
    }
116

            
117
    var description: String {
118
        switch self {
119
        case .magsafe:
120
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
121
        case .genericQi:
122
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
123
        }
124
    }
125
}
126

            
127
struct ChargeCheckpointSummary: Identifiable, Hashable {
128
    let id: UUID
129
    let sessionID: UUID
130
    let chargedDeviceID: UUID
131
    let timestamp: Date
132
    let batteryPercent: Double
133
    let measuredEnergyWh: Double
134
    let measuredChargeAh: Double
135
    let currentAmps: Double
136
    let voltageVolts: Double?
137
    let label: String?
138
}
139

            
140
struct ChargeSessionSampleSummary: Identifiable, Hashable {
141
    let sessionID: UUID
142
    let chargedDeviceID: UUID
143
    let bucketIndex: Int
144
    let timestamp: Date
145
    let averageCurrentAmps: Double
146
    let averageVoltageVolts: Double?
147
    let averagePowerWatts: Double
148
    let measuredEnergyWh: Double
149
    let measuredChargeAh: Double
150
    let sampleCount: Int
151

            
152
    var id: String {
153
        "\(sessionID.uuidString)-\(bucketIndex)"
154
    }
155
}
156

            
157
struct ChargeSessionSummary: Identifiable, Hashable {
158
    let id: UUID
159
    let chargedDeviceID: UUID
160
    let chargerID: UUID?
161
    let meterMACAddress: String?
162
    let meterName: String?
163
    let meterModel: String?
164
    let startedAt: Date
165
    let endedAt: Date?
166
    let lastObservedAt: Date
167
    let status: ChargeSessionStatus
168
    let sourceMode: ChargeSessionSourceMode
169
    let chargingTransportMode: ChargingTransportMode
170
    let measuredEnergyWh: Double
171
    let effectiveBatteryEnergyWh: Double?
172
    let measuredChargeAh: Double
173
    let minimumObservedCurrentAmps: Double?
174
    let maximumObservedCurrentAmps: Double?
175
    let maximumObservedPowerWatts: Double?
176
    let maximumObservedVoltageVolts: Double?
177
    let selectedSourceVoltageVolts: Double?
178
    let completionCurrentAmps: Double?
179
    let stopThresholdAmps: Double
180
    let startBatteryPercent: Double?
181
    let endBatteryPercent: Double?
182
    let capacityEstimateWh: Double?
183
    let wirelessEfficiencyFactor: Double?
184
    let usesEstimatedWirelessEfficiency: Bool
185
    let shouldWarnAboutLowWirelessEfficiency: Bool
186
    let supportsChargingWhileOff: Bool
187
    let usedOfflineMeterCounters: Bool
188
    let targetBatteryPercent: Double?
189
    let targetBatteryAlertTriggeredAt: Date?
190
    let requiresCompletionConfirmation: Bool
191
    let completionConfirmationRequestedAt: Date?
192
    let completionContradictionPercent: Double?
193
    let selectedDataGroup: UInt8?
194
    let checkpoints: [ChargeCheckpointSummary]
195
    let aggregatedSamples: [ChargeSessionSampleSummary]
196

            
197
    var duration: TimeInterval {
198
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
199
    }
200

            
201
    var effectiveOrMeasuredEnergyWh: Double {
202
        effectiveBatteryEnergyWh ?? measuredEnergyWh
203
    }
204

            
205
    var batteryDeltaPercent: Double? {
206
        guard let startBatteryPercent, let endBatteryPercent else { return nil }
207
        return endBatteryPercent - startBatteryPercent
208
    }
209
}
210

            
211
struct BatteryLevelPrediction: Hashable {
212
    let predictedPercent: Double
213
    let estimatedCapacityWh: Double
214
    let anchorPercent: Double
215
    let anchorEnergyWh: Double
216
    let anchorDescription: String
217
}
218

            
219
struct CapacityTrendPoint: Identifiable, Hashable {
220
    let sessionID: UUID
221
    let timestamp: Date
222
    let capacityWh: Double
223
    let chargingTransportMode: ChargingTransportMode
224

            
225
    var id: UUID { sessionID }
226
}
227

            
228
struct TypicalChargeCurvePoint: Identifiable, Hashable {
229
    let percentBin: Int
230
    let averageEnergyWh: Double
231
    let averageChargeAh: Double
232
    let sampleCount: Int
233

            
234
    var id: Int { percentBin }
235
}
236

            
237
struct ChargedDeviceSummary: Identifiable, Hashable {
238
    let id: UUID
239
    let qrIdentifier: String
240
    let name: String
241
    let deviceClass: ChargedDeviceClass
242
    let supportsChargingWhileOff: Bool
243
    let supportsWiredCharging: Bool
244
    let supportsWirelessCharging: Bool
245
    let preferredChargingTransportMode: ChargingTransportMode
246
    let wirelessChargingProfile: WirelessChargingProfile
247
    let wirelessChargerEfficiencyFactor: Double?
248
    let wiredChargeCompletionCurrentAmps: Double?
249
    let wirelessChargeCompletionCurrentAmps: Double?
250
    let chargerObservedVoltageSelections: [Double]
251
    let chargerIdleCurrentAmps: Double?
252
    let chargerEfficiencyFactor: Double?
253
    let chargerMaximumPowerWatts: Double?
254
    let notes: String?
255
    let minimumCurrentAmps: Double?
256
    let estimatedBatteryCapacityWh: Double?
257
    let wiredMinimumCurrentAmps: Double?
258
    let wirelessMinimumCurrentAmps: Double?
259
    let wiredEstimatedBatteryCapacityWh: Double?
260
    let wirelessEstimatedBatteryCapacityWh: Double?
261
    let lastAssociatedMeterMAC: String?
262
    let createdAt: Date
263
    let updatedAt: Date
264
    let sessions: [ChargeSessionSummary]
265
    let capacityHistory: [CapacityTrendPoint]
266
    let typicalCurve: [TypicalChargeCurvePoint]
267

            
268
    var isCharger: Bool {
269
        deviceClass == .charger
270
    }
271

            
272
    var activeSession: ChargeSessionSummary? {
273
        sessions.first(where: { $0.status == .active })
274
    }
275

            
276
    var recentCompletedSessions: [ChargeSessionSummary] {
277
        sessions.filter { $0.status == .completed }
278
    }
279

            
280
    var sessionCount: Int {
281
        sessions.count
282
    }
283

            
284
    var supportedChargingModes: [ChargingTransportMode] {
285
        var modes: [ChargingTransportMode] = []
286
        if supportsWiredCharging {
287
            modes.append(.wired)
288
        }
289
        if supportsWirelessCharging {
290
            modes.append(.wireless)
291
        }
292
        return modes.isEmpty ? [preferredChargingTransportMode] : modes
293
    }
294

            
295
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
296
        switch chargingTransportMode {
297
        case .wired:
298
            return wiredEstimatedBatteryCapacityWh
299
        case .wireless:
300
            return wirelessEstimatedBatteryCapacityWh
301
        }
302
    }
303

            
304
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
305
        switch chargingTransportMode {
306
        case .wired:
307
            return wiredMinimumCurrentAmps
308
        case .wireless:
309
            return wirelessMinimumCurrentAmps
310
        }
311
    }
312

            
313
    func configuredCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
314
        switch chargingTransportMode {
315
        case .wired:
316
            return wiredChargeCompletionCurrentAmps
317
        case .wireless:
318
            return wirelessChargeCompletionCurrentAmps
319
        }
320
    }
321

            
322
    func resolvedCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
323
        configuredCompletionCurrentAmps(for: chargingTransportMode)
324
            ?? minimumCurrentAmps(for: chargingTransportMode)
325
            ?? minimumCurrentAmps
326
    }
327

            
328
    func batteryLevelPrediction(for session: ChargeSessionSummary) -> BatteryLevelPrediction? {
329
        let estimatedCapacityWh = session.capacityEstimateWh
330
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
331
            ?? estimatedBatteryCapacityWh
332

            
333
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
334
            return nil
335
        }
336

            
337
        let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
338

            
339
        struct Anchor {
340
            let percent: Double
341
            let energyWh: Double
342
            let description: String
343
        }
344

            
345
        var anchors: [Anchor] = []
346

            
347
        if let startBatteryPercent = session.startBatteryPercent {
348
            anchors.append(
349
                Anchor(
350
                    percent: startBatteryPercent,
351
                    energyWh: 0,
352
                    description: "session start"
353
                )
354
            )
355
        }
356

            
357
        anchors.append(
358
            contentsOf: session.checkpoints
359
                .sorted { lhs, rhs in
360
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
361
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
362
                    }
363
                    return lhs.timestamp < rhs.timestamp
364
                }
365
                .map { checkpoint in
366
                    let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
367
                    return Anchor(
368
                        percent: checkpoint.batteryPercent,
369
                        energyWh: checkpoint.measuredEnergyWh,
370
                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint"
371
                    )
372
                }
373
        )
374

            
375
        guard !anchors.isEmpty else {
376
            return nil
377
        }
378

            
379
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
380
        let anchor = eligibleAnchors.last ?? anchors.first!
381
        let energyDeltaWh = max(effectiveEnergyWh - anchor.energyWh, 0)
382
        let predictedPercent = min(
383
            100,
384
            max(
385
                0,
386
                anchor.percent + ((energyDeltaWh / estimatedCapacityWh) * 100)
387
            )
388
        )
389

            
390
        return BatteryLevelPrediction(
391
            predictedPercent: predictedPercent,
392
            estimatedCapacityWh: estimatedCapacityWh,
393
            anchorPercent: anchor.percent,
394
            anchorEnergyWh: anchor.energyWh,
395
            anchorDescription: anchor.description
396
        )
397
    }
398
}
399

            
400
struct ChargingMonitorSnapshot {
401
    let meterMACAddress: String
402
    let meterName: String
403
    let meterModel: String
404
    let observedAt: Date
405
    let voltageVolts: Double
406
    let currentAmps: Double
407
    let powerWatts: Double
408
    let selectedDataGroup: UInt8?
409
    let meterChargeCounterAh: Double?
410
    let meterEnergyCounterWh: Double?
411
    let fallbackStopThresholdAmps: Double
412
}