USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
631 lines | 17.725kb
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
Bogdan Timofte authored a month ago
52
    case paused
Bogdan Timofte authored a month ago
53
    case completed
54
    case abandoned
55

            
56
    var title: String {
Bogdan Timofte authored a month ago
57
        switch self {
58
        case .active:
59
            return "Active"
60
        case .paused:
61
            return "Paused"
62
        case .completed:
63
            return "Completed"
64
        case .abandoned:
65
            return "Abandoned"
66
        }
67
    }
68

            
69
    var isOpen: Bool {
70
        switch self {
71
        case .active, .paused:
72
            return true
73
        case .completed, .abandoned:
74
            return false
75
        }
Bogdan Timofte authored a month ago
76
    }
77
}
78

            
79
enum ChargeSessionSourceMode: String {
80
    case live
81
    case offline
82
    case blended
83

            
84
    var title: String {
85
        switch self {
86
        case .live:
87
            return "Live"
88
        case .offline:
89
            return "Offline Counters"
90
        case .blended:
91
            return "Blended"
92
        }
93
    }
94
}
95

            
Bogdan Timofte authored a month ago
96
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
97
    case wired
98
    case wireless
99

            
100
    var id: String { rawValue }
101

            
102
    var title: String {
103
        switch self {
104
        case .wired:
105
            return "Wired"
106
        case .wireless:
107
            return "Wireless"
108
        }
109
    }
110

            
111
    var symbolName: String {
112
        switch self {
113
        case .wired:
114
            return "cable.connector"
115
        case .wireless:
116
            return "dot.radiowaves.left.and.right"
117
        }
118
    }
119
}
120

            
Bogdan Timofte authored a month ago
121
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
122
    case on
123
    case off
124

            
125
    var id: String { rawValue }
126

            
127
    var title: String {
128
        switch self {
129
        case .on:
130
            return "On"
131
        case .off:
132
            return "Off"
133
        }
134
    }
135

            
136
    var description: String {
137
        switch self {
138
        case .on:
139
            return "Device stays powered on while charging."
140
        case .off:
141
            return "Device is powered off while charging."
142
        }
143
    }
144
}
145

            
146
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
147
    case onOnly
148
    case onOrOff
149
    case offOnly
150

            
151
    var id: String { rawValue }
152

            
153
    var title: String {
154
        switch self {
155
        case .onOnly:
156
            return "On Only"
157
        case .onOrOff:
158
            return "On or Off"
159
        case .offOnly:
160
            return "Off Only"
161
        }
162
    }
163

            
164
    var description: String {
165
        switch self {
166
        case .onOnly:
167
            return "The device can be recorded only while it is powered on."
168
        case .onOrOff:
169
            return "The session must specify whether the device is on or off."
170
        case .offOnly:
171
            return "The device can be recorded only while it is powered off."
172
        }
173
    }
174

            
175
    var supportedModes: [ChargingStateMode] {
176
        switch self {
177
        case .onOnly:
178
            return [.on]
179
        case .onOrOff:
180
            return [.on, .off]
181
        case .offOnly:
182
            return [.off]
183
        }
184
    }
185

            
186
    var supportsMultipleModes: Bool {
187
        supportedModes.count > 1
188
    }
189

            
190
    var supportsChargingWhileOff: Bool {
191
        self != .onOnly
192
    }
193

            
194
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
195
        supportsChargingWhileOff ? .onOrOff : .onOnly
196
    }
197
}
198

            
199
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
200
    case wiredOn
201
    case wiredOff
202
    case wirelessOn
203
    case wirelessOff
204

            
205
    var id: String { rawValue }
206

            
207
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
208
        switch (chargingTransportMode, chargingStateMode) {
209
        case (.wired, .on):
210
            self = .wiredOn
211
        case (.wired, .off):
212
            self = .wiredOff
213
        case (.wireless, .on):
214
            self = .wirelessOn
215
        case (.wireless, .off):
216
            self = .wirelessOff
217
        }
218
    }
219

            
220
    var chargingTransportMode: ChargingTransportMode {
221
        switch self {
222
        case .wiredOn, .wiredOff:
223
            return .wired
224
        case .wirelessOn, .wirelessOff:
225
            return .wireless
226
        }
227
    }
228

            
229
    var chargingStateMode: ChargingStateMode {
230
        switch self {
231
        case .wiredOn, .wirelessOn:
232
            return .on
233
        case .wiredOff, .wirelessOff:
234
            return .off
235
        }
236
    }
237

            
238
    var title: String {
239
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
240
    }
241

            
242
    var shortTitle: String {
243
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
244
    }
245
}
246

            
Bogdan Timofte authored a month ago
247
enum WirelessChargingProfile: String, CaseIterable, Identifiable {
248
    case magsafe
249
    case genericQi
250

            
251
    var id: String { rawValue }
252

            
253
    var title: String {
254
        switch self {
255
        case .magsafe:
256
            return "MagSafe"
257
        case .genericQi:
258
            return "Generic Qi"
259
        }
260
    }
261

            
262
    var description: String {
263
        switch self {
264
        case .magsafe:
265
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
266
        case .genericQi:
267
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
268
        }
269
    }
270
}
271

            
272
struct ChargeCheckpointSummary: Identifiable, Hashable {
273
    let id: UUID
274
    let sessionID: UUID
275
    let chargedDeviceID: UUID
276
    let timestamp: Date
277
    let batteryPercent: Double
278
    let measuredEnergyWh: Double
279
    let measuredChargeAh: Double
280
    let currentAmps: Double
281
    let voltageVolts: Double?
282
    let label: String?
283
}
284

            
285
struct ChargeSessionSampleSummary: Identifiable, Hashable {
286
    let sessionID: UUID
287
    let chargedDeviceID: UUID
288
    let bucketIndex: Int
289
    let timestamp: Date
290
    let averageCurrentAmps: Double
291
    let averageVoltageVolts: Double?
292
    let averagePowerWatts: Double
293
    let measuredEnergyWh: Double
294
    let measuredChargeAh: Double
295
    let sampleCount: Int
296

            
297
    var id: String {
298
        "\(sessionID.uuidString)-\(bucketIndex)"
299
    }
300
}
301

            
302
struct ChargeSessionSummary: Identifiable, Hashable {
303
    let id: UUID
304
    let chargedDeviceID: UUID
305
    let chargerID: UUID?
306
    let meterMACAddress: String?
307
    let meterName: String?
308
    let meterModel: String?
309
    let startedAt: Date
310
    let endedAt: Date?
311
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
312
    let pausedAt: Date?
Bogdan Timofte authored a month ago
313
    let status: ChargeSessionStatus
314
    let sourceMode: ChargeSessionSourceMode
315
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
316
    let chargingStateMode: ChargingStateMode
317
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
318
    let measuredEnergyWh: Double
319
    let effectiveBatteryEnergyWh: Double?
320
    let measuredChargeAh: Double
321
    let minimumObservedCurrentAmps: Double?
322
    let maximumObservedCurrentAmps: Double?
323
    let maximumObservedPowerWatts: Double?
324
    let maximumObservedVoltageVolts: Double?
325
    let selectedSourceVoltageVolts: Double?
326
    let completionCurrentAmps: Double?
327
    let stopThresholdAmps: Double
328
    let startBatteryPercent: Double?
329
    let endBatteryPercent: Double?
330
    let capacityEstimateWh: Double?
331
    let wirelessEfficiencyFactor: Double?
332
    let usesEstimatedWirelessEfficiency: Bool
333
    let shouldWarnAboutLowWirelessEfficiency: Bool
334
    let supportsChargingWhileOff: Bool
335
    let usedOfflineMeterCounters: Bool
336
    let targetBatteryPercent: Double?
337
    let targetBatteryAlertTriggeredAt: Date?
338
    let requiresCompletionConfirmation: Bool
339
    let completionConfirmationRequestedAt: Date?
340
    let completionContradictionPercent: Double?
341
    let selectedDataGroup: UInt8?
342
    let checkpoints: [ChargeCheckpointSummary]
343
    let aggregatedSamples: [ChargeSessionSampleSummary]
344

            
Bogdan Timofte authored a month ago
345
    var sessionKind: ChargeSessionKind {
346
        ChargeSessionKind(
347
            chargingTransportMode: chargingTransportMode,
348
            chargingStateMode: chargingStateMode
349
        )
350
    }
351

            
Bogdan Timofte authored a month ago
352
    var duration: TimeInterval {
353
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
354
    }
355

            
356
    var effectiveOrMeasuredEnergyWh: Double {
357
        effectiveBatteryEnergyWh ?? measuredEnergyWh
358
    }
359

            
360
    var batteryDeltaPercent: Double? {
361
        guard let startBatteryPercent, let endBatteryPercent else { return nil }
362
        return endBatteryPercent - startBatteryPercent
363
    }
Bogdan Timofte authored a month ago
364

            
365
    var canAutoStop: Bool {
366
        autoStopEnabled && stopThresholdAmps > 0
367
    }
368

            
369
    var isPaused: Bool {
370
        status == .paused
371
    }
372

            
373
    var isOpen: Bool {
374
        status.isOpen
375
    }
Bogdan Timofte authored a month ago
376
}
377

            
378
struct BatteryLevelPrediction: Hashable {
379
    let predictedPercent: Double
380
    let estimatedCapacityWh: Double
381
    let anchorPercent: Double
382
    let anchorEnergyWh: Double
383
    let anchorDescription: String
384
}
385

            
386
struct CapacityTrendPoint: Identifiable, Hashable {
387
    let sessionID: UUID
388
    let timestamp: Date
389
    let capacityWh: Double
390
    let chargingTransportMode: ChargingTransportMode
391

            
392
    var id: UUID { sessionID }
393
}
394

            
395
struct TypicalChargeCurvePoint: Identifiable, Hashable {
396
    let percentBin: Int
397
    let averageEnergyWh: Double
398
    let averageChargeAh: Double
399
    let sampleCount: Int
400

            
401
    var id: Int { percentBin }
402
}
403

            
404
struct ChargedDeviceSummary: Identifiable, Hashable {
405
    let id: UUID
406
    let qrIdentifier: String
407
    let name: String
408
    let deviceClass: ChargedDeviceClass
409
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
410
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
411
    let supportsWiredCharging: Bool
412
    let supportsWirelessCharging: Bool
413
    let preferredChargingTransportMode: ChargingTransportMode
414
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
415
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
416
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
417
    let wirelessChargerEfficiencyFactor: Double?
418
    let wiredChargeCompletionCurrentAmps: Double?
419
    let wirelessChargeCompletionCurrentAmps: Double?
420
    let chargerObservedVoltageSelections: [Double]
421
    let chargerIdleCurrentAmps: Double?
422
    let chargerEfficiencyFactor: Double?
423
    let chargerMaximumPowerWatts: Double?
424
    let notes: String?
425
    let minimumCurrentAmps: Double?
426
    let estimatedBatteryCapacityWh: Double?
427
    let wiredMinimumCurrentAmps: Double?
428
    let wirelessMinimumCurrentAmps: Double?
429
    let wiredEstimatedBatteryCapacityWh: Double?
430
    let wirelessEstimatedBatteryCapacityWh: Double?
431
    let lastAssociatedMeterMAC: String?
432
    let createdAt: Date
433
    let updatedAt: Date
434
    let sessions: [ChargeSessionSummary]
435
    let capacityHistory: [CapacityTrendPoint]
436
    let typicalCurve: [TypicalChargeCurvePoint]
437

            
438
    var isCharger: Bool {
439
        deviceClass == .charger
440
    }
441

            
442
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
443
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
444
    }
445

            
446
    var recentCompletedSessions: [ChargeSessionSummary] {
447
        sessions.filter { $0.status == .completed }
448
    }
449

            
450
    var sessionCount: Int {
451
        sessions.count
452
    }
453

            
454
    var supportedChargingModes: [ChargingTransportMode] {
455
        var modes: [ChargingTransportMode] = []
456
        if supportsWiredCharging {
457
            modes.append(.wired)
458
        }
459
        if supportsWirelessCharging {
460
            modes.append(.wireless)
461
        }
462
        return modes.isEmpty ? [preferredChargingTransportMode] : modes
463
    }
464

            
Bogdan Timofte authored a month ago
465
    var supportedChargingStateModes: [ChargingStateMode] {
466
        chargingStateAvailability.supportedModes
467
    }
468

            
469
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
470
        if let matchingSession = sessions.first(where: {
471
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
472
        }) {
473
            return matchingSession.chargingStateMode
474
        }
475
        return chargingStateAvailability.supportedModes.first ?? .on
476
    }
477

            
478
    func sessionKind(
479
        for chargingTransportMode: ChargingTransportMode,
480
        chargingStateMode: ChargingStateMode? = nil
481
    ) -> ChargeSessionKind {
482
        ChargeSessionKind(
483
            chargingTransportMode: chargingTransportMode,
484
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
485
        )
486
    }
487

            
Bogdan Timofte authored a month ago
488
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
489
        switch chargingTransportMode {
490
        case .wired:
491
            return wiredEstimatedBatteryCapacityWh
492
        case .wireless:
493
            return wirelessEstimatedBatteryCapacityWh
494
        }
495
    }
496

            
497
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
498
        switch chargingTransportMode {
499
        case .wired:
500
            return wiredMinimumCurrentAmps
501
        case .wireless:
502
            return wirelessMinimumCurrentAmps
503
        }
504
    }
505

            
Bogdan Timofte authored a month ago
506
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
507
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
508
            return explicitCurrent
509
        }
510

            
511
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
512
        case .wired:
513
            return wiredChargeCompletionCurrentAmps
514
        case .wireless:
515
            return wirelessChargeCompletionCurrentAmps
516
        }
517
    }
518

            
Bogdan Timofte authored a month ago
519
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
520
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
521
            return learnedCurrent
522
        }
523

            
524
        switch sessionKind.chargingTransportMode {
525
        case .wired:
526
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
527
        case .wireless:
528
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
529
        }
530
    }
531

            
532
    func resolvedCompletionCurrentAmps(
533
        for chargingTransportMode: ChargingTransportMode,
534
        chargingStateMode: ChargingStateMode? = nil
535
    ) -> Double? {
536
        let sessionKind = sessionKind(
537
            for: chargingTransportMode,
538
            chargingStateMode: chargingStateMode
539
        )
540

            
541
        return configuredCompletionCurrentAmps(for: sessionKind)
542
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
543
            ?? minimumCurrentAmps(for: chargingTransportMode)
544
            ?? minimumCurrentAmps
545
    }
546

            
547
    func batteryLevelPrediction(for session: ChargeSessionSummary) -> BatteryLevelPrediction? {
548
        let estimatedCapacityWh = session.capacityEstimateWh
549
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
550
            ?? estimatedBatteryCapacityWh
551

            
552
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
553
            return nil
554
        }
555

            
556
        let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
557

            
558
        struct Anchor {
559
            let percent: Double
560
            let energyWh: Double
561
            let description: String
562
        }
563

            
564
        var anchors: [Anchor] = []
565

            
566
        if let startBatteryPercent = session.startBatteryPercent {
567
            anchors.append(
568
                Anchor(
569
                    percent: startBatteryPercent,
570
                    energyWh: 0,
571
                    description: "session start"
572
                )
573
            )
574
        }
575

            
576
        anchors.append(
577
            contentsOf: session.checkpoints
578
                .sorted { lhs, rhs in
579
                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
580
                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
581
                    }
582
                    return lhs.timestamp < rhs.timestamp
583
                }
584
                .map { checkpoint in
585
                    let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
586
                    return Anchor(
587
                        percent: checkpoint.batteryPercent,
588
                        energyWh: checkpoint.measuredEnergyWh,
589
                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint"
590
                    )
591
                }
592
        )
593

            
594
        guard !anchors.isEmpty else {
595
            return nil
596
        }
597

            
598
        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
599
        let anchor = eligibleAnchors.last ?? anchors.first!
600
        let energyDeltaWh = max(effectiveEnergyWh - anchor.energyWh, 0)
601
        let predictedPercent = min(
602
            100,
603
            max(
604
                0,
605
                anchor.percent + ((energyDeltaWh / estimatedCapacityWh) * 100)
606
            )
607
        )
608

            
609
        return BatteryLevelPrediction(
610
            predictedPercent: predictedPercent,
611
            estimatedCapacityWh: estimatedCapacityWh,
612
            anchorPercent: anchor.percent,
613
            anchorEnergyWh: anchor.energyWh,
614
            anchorDescription: anchor.description
615
        )
616
    }
617
}
618

            
619
struct ChargingMonitorSnapshot {
620
    let meterMACAddress: String
621
    let meterName: String
622
    let meterModel: String
623
    let observedAt: Date
624
    let voltageVolts: Double
625
    let currentAmps: Double
626
    let powerWatts: Double
627
    let selectedDataGroup: UInt8?
628
    let meterChargeCounterAh: Double?
629
    let meterEnergyCounterWh: Double?
630
    let fallbackStopThresholdAmps: Double
631
}