USB-Meter / USB Meter / Model / ChargeInsightsModel.swift
Newer Older
2127 lines | 71.838kb
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

            
Bogdan Timofte authored a month ago
10
enum ChargedDeviceKind: String, Identifiable, Codable {
Bogdan Timofte authored a month ago
11
    case device
12
    case charger
13

            
14
    var id: String { rawValue }
15

            
16
    var title: String {
17
        switch self {
18
        case .device:
19
            return "Device"
20
        case .charger:
21
            return "Charger"
22
        }
23
    }
24

            
25
    var pluralTitle: String {
26
        switch self {
27
        case .device:
28
            return "Devices"
29
        case .charger:
30
            return "Chargers"
31
        }
32
    }
33

            
34
    var symbolName: String {
35
        switch self {
36
        case .device:
37
            return "iphone"
38
        case .charger:
39
            return "bolt.horizontal.circle"
40
        }
41
    }
42
}
43

            
Bogdan Timofte authored a month ago
44
enum ChargedDeviceClass: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
45
    case iphone
46
    case watch
47
    case powerbank
48
    case charger
49
    case other
50

            
51
    var id: String { rawValue }
52

            
Bogdan Timofte authored a month ago
53
    static var deviceCases: [ChargedDeviceClass] {
54
        allCases.filter { $0 != .charger }
55
    }
56

            
57
    var kind: ChargedDeviceKind {
58
        self == .charger ? .charger : .device
59
    }
60

            
Bogdan Timofte authored a month ago
61
    var title: String {
62
        switch self {
63
        case .iphone:
64
            return "iPhone"
65
        case .watch:
66
            return "Watch"
67
        case .powerbank:
68
            return "Powerbank"
69
        case .charger:
70
            return "Charger"
71
        case .other:
72
            return "Other"
73
        }
74
    }
75

            
76
    var symbolName: String {
77
        switch self {
78
        case .iphone:
79
            return "iphone"
80
        case .watch:
81
            return "applewatch"
82
        case .powerbank:
83
            return "battery.100.bolt"
84
        case .charger:
85
            return "bolt.badge.clock"
86
        case .other:
87
            return "shippingbox"
88
        }
89
    }
Bogdan Timofte authored a month ago
90

            
91
    var enforcedChargingSupport: (wired: Bool, wireless: Bool)? {
92
        switch self {
93
        case .watch:
94
            return (wired: false, wireless: true)
95
        case .powerbank:
96
            return (wired: true, wireless: false)
97
        case .charger:
98
            return (wired: false, wireless: true)
99
        case .iphone, .other:
100
            return nil
101
        }
102
    }
103

            
104
    var enforcedChargingStateAvailability: ChargingStateAvailability? {
105
        switch self {
106
        case .watch:
107
            return .onOnly
108
        case .powerbank:
109
            return .offOnly
110
        case .charger:
111
            return .onOnly
112
        case .iphone, .other:
113
            return nil
114
        }
115
    }
116

            
Bogdan Timofte authored a month ago
117
    var defaultChargingSupport: (wired: Bool, wireless: Bool) {
118
        if let enforcedChargingSupport {
119
            return enforcedChargingSupport
120
        }
121

            
122
        switch self {
123
        case .iphone:
124
            return (wired: true, wireless: true)
125
        case .watch:
126
            return (wired: false, wireless: true)
127
        case .powerbank:
128
            return (wired: true, wireless: false)
129
        case .charger:
130
            return (wired: false, wireless: true)
131
        case .other:
132
            return (wired: true, wireless: false)
133
        }
134
    }
135

            
136
    var defaultChargingStateAvailability: ChargingStateAvailability {
137
        enforcedChargingStateAvailability ?? {
138
            switch self {
139
            case .iphone:
140
                return .onOrOff
141
            case .watch:
142
                return .onOnly
143
            case .powerbank:
144
                return .offOnly
145
            case .charger, .other:
146
                return .onOrOff
147
            }
148
        }()
149
    }
150

            
Bogdan Timofte authored a month ago
151
    func normalizedChargingSupport(
152
        supportsWiredCharging: Bool,
153
        supportsWirelessCharging: Bool
154
    ) -> (wired: Bool, wireless: Bool) {
155
        enforcedChargingSupport ?? (wired: supportsWiredCharging, wireless: supportsWirelessCharging)
156
    }
157

            
158
    func normalizedChargingStateAvailability(
159
        _ chargingStateAvailability: ChargingStateAvailability
160
    ) -> ChargingStateAvailability {
161
        enforcedChargingStateAvailability ?? chargingStateAvailability
162
    }
Bogdan Timofte authored a month ago
163
}
164

            
165
enum ChargeSessionStatus: String {
166
    case active
Bogdan Timofte authored a month ago
167
    case paused
Bogdan Timofte authored a month ago
168
    case completed
169
    case abandoned
170

            
171
    var title: String {
Bogdan Timofte authored a month ago
172
        switch self {
173
        case .active:
174
            return "Active"
175
        case .paused:
176
            return "Paused"
177
        case .completed:
178
            return "Completed"
179
        case .abandoned:
180
            return "Abandoned"
181
        }
182
    }
183

            
184
    var isOpen: Bool {
185
        switch self {
186
        case .active, .paused:
187
            return true
188
        case .completed, .abandoned:
189
            return false
190
        }
Bogdan Timofte authored a month ago
191
    }
192
}
193

            
194
enum ChargeSessionSourceMode: String {
195
    case live
196
    case offline
197
    case blended
198

            
199
    var title: String {
200
        switch self {
201
        case .live:
202
            return "Live"
203
        case .offline:
204
            return "Offline Counters"
205
        case .blended:
206
            return "Blended"
207
        }
208
    }
209
}
210

            
Bogdan Timofte authored a month ago
211
enum ChargingTransportMode: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
212
    case wired
213
    case wireless
214

            
215
    var id: String { rawValue }
216

            
217
    var title: String {
218
        switch self {
219
        case .wired:
220
            return "Wired"
221
        case .wireless:
222
            return "Wireless"
223
        }
224
    }
225

            
226
    var symbolName: String {
227
        switch self {
228
        case .wired:
229
            return "cable.connector"
230
        case .wireless:
231
            return "dot.radiowaves.left.and.right"
232
        }
233
    }
234
}
235

            
Bogdan Timofte authored a month ago
236
enum ChargingStateMode: String, CaseIterable, Identifiable, Codable {
237
    case on
238
    case off
239

            
240
    var id: String { rawValue }
241

            
242
    var title: String {
243
        switch self {
244
        case .on:
245
            return "On"
246
        case .off:
247
            return "Off"
248
        }
249
    }
250

            
251
    var description: String {
252
        switch self {
253
        case .on:
254
            return "Device stays powered on while charging."
255
        case .off:
256
            return "Device is powered off while charging."
257
        }
258
    }
259
}
260

            
261
enum ChargingStateAvailability: String, CaseIterable, Identifiable, Codable {
262
    case onOnly
263
    case onOrOff
264
    case offOnly
265

            
266
    var id: String { rawValue }
267

            
268
    var title: String {
269
        switch self {
270
        case .onOnly:
271
            return "On Only"
272
        case .onOrOff:
273
            return "On or Off"
274
        case .offOnly:
275
            return "Off Only"
276
        }
277
    }
278

            
279
    var description: String {
280
        switch self {
281
        case .onOnly:
282
            return "The device can be recorded only while it is powered on."
283
        case .onOrOff:
284
            return "The session must specify whether the device is on or off."
285
        case .offOnly:
286
            return "The device can be recorded only while it is powered off."
287
        }
288
    }
289

            
290
    var supportedModes: [ChargingStateMode] {
291
        switch self {
292
        case .onOnly:
293
            return [.on]
294
        case .onOrOff:
295
            return [.on, .off]
296
        case .offOnly:
297
            return [.off]
298
        }
299
    }
300

            
301
    var supportsMultipleModes: Bool {
302
        supportedModes.count > 1
303
    }
304

            
305
    var supportsChargingWhileOff: Bool {
306
        self != .onOnly
307
    }
308

            
309
    static func fallback(for supportsChargingWhileOff: Bool) -> ChargingStateAvailability {
310
        supportsChargingWhileOff ? .onOrOff : .onOnly
311
    }
312
}
313

            
314
enum ChargeSessionKind: String, CaseIterable, Identifiable, Codable, Hashable {
315
    case wiredOn
316
    case wiredOff
317
    case wirelessOn
318
    case wirelessOff
319

            
320
    var id: String { rawValue }
321

            
322
    init(chargingTransportMode: ChargingTransportMode, chargingStateMode: ChargingStateMode) {
323
        switch (chargingTransportMode, chargingStateMode) {
324
        case (.wired, .on):
325
            self = .wiredOn
326
        case (.wired, .off):
327
            self = .wiredOff
328
        case (.wireless, .on):
329
            self = .wirelessOn
330
        case (.wireless, .off):
331
            self = .wirelessOff
332
        }
333
    }
334

            
335
    var chargingTransportMode: ChargingTransportMode {
336
        switch self {
337
        case .wiredOn, .wiredOff:
338
            return .wired
339
        case .wirelessOn, .wirelessOff:
340
            return .wireless
341
        }
342
    }
343

            
344
    var chargingStateMode: ChargingStateMode {
345
        switch self {
346
        case .wiredOn, .wirelessOn:
347
            return .on
348
        case .wiredOff, .wirelessOff:
349
            return .off
350
        }
351
    }
352

            
353
    var title: String {
354
        "\(chargingTransportMode.title) • \(chargingStateMode.title)"
355
    }
356

            
357
    var shortTitle: String {
358
        "\(chargingTransportMode.title) \(chargingStateMode.title)"
359
    }
360
}
361

            
Bogdan Timofte authored a month ago
362
enum WirelessChargingProfile: String, CaseIterable, Identifiable, Codable {
Bogdan Timofte authored a month ago
363
    case magsafe
364
    case genericQi
365

            
366
    var id: String { rawValue }
367

            
368
    var title: String {
369
        switch self {
370
        case .magsafe:
371
            return "MagSafe"
372
        case .genericQi:
373
            return "Generic Qi"
374
        }
375
    }
376

            
377
    var description: String {
378
        switch self {
379
        case .magsafe:
380
            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
381
        case .genericQi:
382
            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
383
        }
384
    }
385
}
386

            
Bogdan Timofte authored a month ago
387
enum ChargerType: String, CaseIterable, Identifiable, Codable {
388
    case appleMagSafe
389
    case appleWatch
390
    case genericMagSafe
391
    case genericQi
392

            
393
    var id: String { rawValue }
394

            
395
    var title: String {
396
        switch self {
397
        case .appleMagSafe: return "Apple MagSafe Charger"
398
        case .appleWatch: return "Apple Watch Charger"
399
        case .genericMagSafe: return "Generic MagSafe"
400
        case .genericQi: return "Generic Qi"
401
        }
402
    }
403

            
404
    var symbolName: String {
405
        switch self {
406
        case .appleMagSafe: return "magsafe.batterypack"
407
        case .appleWatch: return "applewatch.radiowaves.left.and.right"
408
        case .genericMagSafe: return "bolt.circle"
409
        case .genericQi: return "bolt.horizontal.circle"
410
        }
411
    }
412

            
413
    /// Whether this charger type uses magnetic alignment, enabling more accurate efficiency calibration.
414
    var supportsAlignment: Bool {
415
        switch self {
416
        case .appleMagSafe, .appleWatch, .genericMagSafe: return true
417
        case .genericQi: return false
418
        }
419
    }
420

            
421
    var wirelessChargingProfile: WirelessChargingProfile {
422
        supportsAlignment ? .magsafe : .genericQi
423
    }
424
}
425

            
Bogdan Timofte authored a month ago
426
enum ChargedDeviceTemplateIconSource: String, Codable {
427
    case systemSymbol
428
    case asset
429
}
430

            
431
struct ChargedDeviceTemplateIcon: Hashable, Codable {
432
    let type: ChargedDeviceTemplateIconSource
433
    let name: String
434
    let fallbackSystemName: String?
435

            
436
    static func systemSymbol(
437
        _ name: String,
438
        fallbackSystemName: String? = nil
439
    ) -> ChargedDeviceTemplateIcon {
440
        ChargedDeviceTemplateIcon(
441
            type: .systemSymbol,
442
            name: name,
443
            fallbackSystemName: fallbackSystemName
444
        )
445
    }
446

            
447
    func resolvedSystemSymbolName(fallbackSystemName: String) -> String {
448
        switch type {
449
        case .systemSymbol:
450
            return name
451
        case .asset:
452
            return self.fallbackSystemName ?? fallbackSystemName
453
        }
454
    }
455
}
456

            
457
struct ChargedDeviceTemplateDefinition: Identifiable, Hashable, Codable {
458
    let id: String
459
    let name: String
460
    let group: String
461
    let kind: ChargedDeviceKind
462
    let deviceClass: ChargedDeviceClass
463
    let icon: ChargedDeviceTemplateIcon
464
    let chargingStateAvailability: ChargingStateAvailability
465
    let supportsWiredCharging: Bool
466
    let supportsWirelessCharging: Bool
467
    let wirelessChargingProfile: WirelessChargingProfile
468
    let sortOrder: Int
469

            
470
    var chargingSupportSummary: String {
471
        switch (supportsWiredCharging, supportsWirelessCharging) {
472
        case (true, true):
473
            return "Wired + Wireless"
474
        case (true, false):
475
            return "Wired only"
476
        case (false, true):
477
            return "Wireless only"
478
        case (false, false):
479
            return "No charging transport"
480
        }
481
    }
482

            
483
    var capabilitySummary: String {
Bogdan Timofte authored a month ago
484
        if kind == .charger {
485
            return wirelessChargingProfile.title
486
        }
Bogdan Timofte authored a month ago
487
        var components = [chargingStateAvailability.title, chargingSupportSummary]
488
        if supportsWirelessCharging {
489
            components.append(wirelessChargingProfile.title)
490
        }
491
        return components.joined(separator: " • ")
492
    }
493
}
494

            
495
private struct ChargedDeviceTemplateDocument: Codable {
496
    let templates: [ChargedDeviceTemplateDefinition]
497
}
498

            
499
struct ChargedDeviceTemplateCatalog {
500
    static let shared = ChargedDeviceTemplateCatalog()
501

            
502
    let templates: [ChargedDeviceTemplateDefinition]
503
    private let templatesByID: [String: ChargedDeviceTemplateDefinition]
504

            
505
    private init(bundle: Bundle = .main) {
506
        let loadedTemplates: [ChargedDeviceTemplateDefinition]
507

            
508
        if let resourceURL = bundle.url(forResource: "ChargedDeviceTemplates", withExtension: "json"),
509
           let data = try? Data(contentsOf: resourceURL),
510
           let document = try? JSONDecoder().decode(ChargedDeviceTemplateDocument.self, from: data) {
511
            loadedTemplates = document.templates
512
        } else {
513
            loadedTemplates = []
514
        }
515

            
516
        self.templates = loadedTemplates.sorted { lhs, rhs in
517
            if lhs.group != rhs.group {
518
                return lhs.group < rhs.group
519
            }
520
            if lhs.sortOrder != rhs.sortOrder {
521
                return lhs.sortOrder < rhs.sortOrder
522
            }
523
            return lhs.name < rhs.name
524
        }
525
        self.templatesByID = Dictionary(uniqueKeysWithValues: self.templates.map { ($0.id, $0) })
526
    }
527

            
528
    func template(id: String?) -> ChargedDeviceTemplateDefinition? {
529
        guard let id else {
530
            return nil
531
        }
532
        return templatesByID[id]
533
    }
534

            
535
    func templates(for kind: ChargedDeviceKind) -> [ChargedDeviceTemplateDefinition] {
536
        templates.filter { $0.kind == kind }
537
    }
538
}
539

            
Bogdan Timofte authored a month ago
540
enum ProfileCategory: String, CaseIterable, Identifiable, Codable {
541
    case phone
542
    case tablet
543
    case laptop
544
    case watch
545
    case audioAccessory
546
    case accessoryCase
547
    case charger
548
    case powerbank
549
    case other
550

            
551
    var id: String { rawValue }
552

            
553
    var title: String {
554
        switch self {
555
        case .phone: return "Phone"
556
        case .tablet: return "Tablet"
557
        case .laptop: return "Laptop"
558
        case .watch: return "Watch"
559
        case .audioAccessory: return "Audio Accessory"
560
        case .accessoryCase: return "Charging Case"
561
        case .charger: return "Charger"
562
        case .powerbank: return "Powerbank"
563
        case .other: return "Other"
564
        }
565
    }
566

            
567
    var pluralTitle: String {
568
        switch self {
569
        case .phone: return "Phones"
570
        case .tablet: return "Tablets"
571
        case .laptop: return "Laptops"
572
        case .watch: return "Watches"
573
        case .audioAccessory: return "Audio Accessories"
574
        case .accessoryCase: return "Charging Cases"
575
        case .charger: return "Chargers"
576
        case .powerbank: return "Powerbanks"
577
        case .other: return "Other"
578
        }
579
    }
580

            
581
    var symbolName: String {
582
        switch self {
583
        case .phone: return "iphone"
584
        case .tablet: return "ipad"
585
        case .laptop: return "laptopcomputer"
586
        case .watch: return "applewatch"
587
        case .audioAccessory: return "earbuds.case"
588
        case .accessoryCase: return "airpods.case.fill"
589
        case .charger: return "bolt.horizontal.circle"
590
        case .powerbank: return "battery.100.bolt"
591
        case .other: return "shippingbox"
592
        }
593
    }
594

            
595
    var kind: ChargedDeviceKind {
596
        self == .charger ? .charger : .device
597
    }
598

            
599
    static func fromLegacyDeviceClass(_ deviceClass: ChargedDeviceClass) -> ProfileCategory {
600
        switch deviceClass {
601
        case .iphone: return .phone
602
        case .watch: return .watch
603
        case .powerbank: return .powerbank
604
        case .charger: return .charger
605
        case .other: return .other
606
        }
607
    }
608
}
609

            
610
struct DeviceProfileDefinition: Identifiable, Hashable, Codable {
611
    let id: String
612
    let name: String
613
    let group: String
614
    let category: ProfileCategory
615
    let icon: ChargedDeviceTemplateIcon
616
    let sortOrder: Int
617

            
618
    let capWiredCharging: Bool
619
    let capWirelessCharging: Bool
620
    let capWirelessProfiles: [WirelessChargingProfile]
621
    let capChargingStateAvailability: ChargingStateAvailability
622
    let capHasInternalSubject: Bool
623

            
624
    let defaultWirelessChargingProfile: WirelessChargingProfile?
625
    let defaultWiredMinimumCurrentAmps: Double?
626
    let defaultWirelessMinimumCurrentAmps: Double?
627
    let defaultWiredEstimatedBatteryCapacityWh: Double?
628
    let defaultWirelessEstimatedBatteryCapacityWh: Double?
629

            
630
    init(
631
        id: String,
632
        name: String,
633
        group: String,
634
        category: ProfileCategory,
635
        icon: ChargedDeviceTemplateIcon,
636
        sortOrder: Int,
637
        capWiredCharging: Bool,
638
        capWirelessCharging: Bool,
639
        capWirelessProfiles: [WirelessChargingProfile],
640
        capChargingStateAvailability: ChargingStateAvailability,
641
        capHasInternalSubject: Bool,
642
        defaultWirelessChargingProfile: WirelessChargingProfile? = nil,
643
        defaultWiredMinimumCurrentAmps: Double? = nil,
644
        defaultWirelessMinimumCurrentAmps: Double? = nil,
645
        defaultWiredEstimatedBatteryCapacityWh: Double? = nil,
646
        defaultWirelessEstimatedBatteryCapacityWh: Double? = nil
647
    ) {
648
        self.id = id
649
        self.name = name
650
        self.group = group
651
        self.category = category
652
        self.icon = icon
653
        self.sortOrder = sortOrder
654
        self.capWiredCharging = capWiredCharging
655
        self.capWirelessCharging = capWirelessCharging
656
        self.capWirelessProfiles = capWirelessProfiles
657
        self.capChargingStateAvailability = capChargingStateAvailability
658
        self.capHasInternalSubject = capHasInternalSubject
659
        self.defaultWirelessChargingProfile = defaultWirelessChargingProfile
660
        self.defaultWiredMinimumCurrentAmps = defaultWiredMinimumCurrentAmps
661
        self.defaultWirelessMinimumCurrentAmps = defaultWirelessMinimumCurrentAmps
662
        self.defaultWiredEstimatedBatteryCapacityWh = defaultWiredEstimatedBatteryCapacityWh
663
        self.defaultWirelessEstimatedBatteryCapacityWh = defaultWirelessEstimatedBatteryCapacityWh
664
    }
665

            
666
    var capabilitySummary: String {
667
        var components: [String] = [capChargingStateAvailability.title]
668
        switch (capWiredCharging, capWirelessCharging) {
669
        case (true, true): components.append("Wired + Wireless")
670
        case (true, false): components.append("Wired only")
671
        case (false, true): components.append("Wireless only")
672
        case (false, false): components.append("No transport")
673
        }
674
        if capWirelessCharging, let primary = defaultWirelessChargingProfile {
675
            components.append(primary.title)
676
        }
677
        return components.joined(separator: " • ")
678
    }
679

            
680
    var wirelessProfilesCSV: String {
681
        capWirelessProfiles.map { $0.rawValue }.joined(separator: ",")
682
    }
683

            
684
    static func decodeWirelessProfilesCSV(_ csv: String?) -> [WirelessChargingProfile] {
685
        guard let csv, !csv.isEmpty else { return [] }
686
        return csv
687
            .split(separator: ",")
688
            .compactMap { WirelessChargingProfile(rawValue: String($0).trimmingCharacters(in: .whitespaces)) }
689
    }
690
}
691

            
692
private struct DeviceProfileCatalogDocument: Codable {
693
    let profiles: [DeviceProfileDefinition]
694
}
695

            
696
struct DeviceProfileCatalog {
697
    static let shared = DeviceProfileCatalog()
698

            
699
    let profiles: [DeviceProfileDefinition]
700
    private let profilesByID: [String: DeviceProfileDefinition]
701

            
702
    private init(bundle: Bundle = .main) {
703
        let loaded: [DeviceProfileDefinition]
704

            
705
        if let resourceURL = bundle.url(forResource: "DeviceProfilesCatalog", withExtension: "json"),
706
           let data = try? Data(contentsOf: resourceURL),
707
           let document = try? JSONDecoder().decode(DeviceProfileCatalogDocument.self, from: data) {
708
            loaded = document.profiles
709
        } else {
710
            loaded = []
711
        }
712

            
713
        self.profiles = loaded.sorted { lhs, rhs in
714
            if lhs.group != rhs.group {
715
                return lhs.group < rhs.group
716
            }
717
            if lhs.sortOrder != rhs.sortOrder {
718
                return lhs.sortOrder < rhs.sortOrder
719
            }
720
            return lhs.name < rhs.name
721
        }
722
        self.profilesByID = Dictionary(uniqueKeysWithValues: self.profiles.map { ($0.id, $0) })
723
    }
724

            
725
    func profile(id: String?) -> DeviceProfileDefinition? {
726
        guard let id else { return nil }
727
        return profilesByID[id]
728
    }
729

            
730
    func profiles(for category: ProfileCategory) -> [DeviceProfileDefinition] {
731
        profiles.filter { $0.category == category }
732
    }
733
}
734

            
735
/// Centralizes the autoexclusion rules that turn a `DeviceProfile` into a coherent
736
/// device state. Called from the editor at edit time so impossible combinations are
737
/// not even expressible — instead of being silently corrected at read time.
738
enum DeviceProfileValidator {
739
    struct AppliedState: Equatable {
740
        var chargingStateAvailability: ChargingStateAvailability
741
        var supportsWiredCharging: Bool
742
        var supportsWirelessCharging: Bool
743
        var wirelessChargingProfile: WirelessChargingProfile
744
        var hasInternalSubject: Bool
745
    }
746

            
747
    /// Returns the canonical state for a freshly selected profile.
748
    /// Used both when the user picks a profile in the editor and when seeding
749
    /// new device defaults from a catalog entry.
750
    static func canonicalState(for profile: DeviceProfileDefinition) -> AppliedState {
751
        AppliedState(
752
            chargingStateAvailability: profile.capChargingStateAvailability,
753
            supportsWiredCharging: profile.capWiredCharging,
754
            supportsWirelessCharging: profile.capWirelessCharging,
755
            wirelessChargingProfile: profile.defaultWirelessChargingProfile
756
                ?? profile.capWirelessProfiles.first
757
                ?? .genericQi,
758
            hasInternalSubject: false
759
        )
760
    }
761

            
762
    /// Coerces a possibly-contradictory state to fit the profile's capabilities.
763
    /// Preserves user-set values where they are still allowed; otherwise falls
764
    /// back to canonical defaults.
765
    static func coerce(
766
        state: AppliedState,
767
        to profile: DeviceProfileDefinition
768
    ) -> AppliedState {
769
        var coerced = state
770
        coerced.supportsWiredCharging = state.supportsWiredCharging && profile.capWiredCharging
771
        coerced.supportsWirelessCharging = state.supportsWirelessCharging && profile.capWirelessCharging
772
        if !coerced.supportsWiredCharging && !coerced.supportsWirelessCharging {
773
            coerced.supportsWiredCharging = profile.capWiredCharging
774
            coerced.supportsWirelessCharging = profile.capWirelessCharging
775
        }
776
        coerced.chargingStateAvailability = profile.capChargingStateAvailability
777
        if !profile.capWirelessProfiles.contains(state.wirelessChargingProfile) {
778
            coerced.wirelessChargingProfile = profile.defaultWirelessChargingProfile
779
                ?? profile.capWirelessProfiles.first
780
                ?? .genericQi
781
        }
782
        if !profile.capHasInternalSubject {
783
            coerced.hasInternalSubject = false
784
        }
785
        return coerced
786
    }
787

            
788
    /// True when the editor should offer the user a toggle for wired charging.
789
    /// (False means the profile forbids wired entirely — hide the row.)
790
    static func allowsWiredToggle(_ profile: DeviceProfileDefinition) -> Bool {
791
        profile.capWiredCharging
792
    }
793

            
794
    static func allowsWirelessToggle(_ profile: DeviceProfileDefinition) -> Bool {
795
        profile.capWirelessCharging
796
    }
797

            
798
    /// True when both transports are permitted — meaning the user may opt out of
799
    /// either; otherwise the surviving transport is mandatory.
800
    static func allowsTransportChoice(_ profile: DeviceProfileDefinition) -> Bool {
801
        profile.capWiredCharging && profile.capWirelessCharging
802
    }
803

            
804
    /// True when there is more than one wireless profile to choose from for this
805
    /// catalog entry. Shown as a picker; otherwise hidden (single value implied).
806
    static func allowsWirelessProfileChoice(_ profile: DeviceProfileDefinition) -> Bool {
807
        profile.capWirelessProfiles.count > 1
808
    }
809

            
810
    /// True when the profile's `capChargingStateAvailability` is fixed to a single
811
    /// state mode — in which case the editor renders a locked badge instead of a picker.
812
    static func chargingStateIsLocked(_ profile: DeviceProfileDefinition) -> Bool {
813
        profile.capChargingStateAvailability == .onOnly
814
            || profile.capChargingStateAvailability == .offOnly
815
    }
816
}
817

            
Bogdan Timofte authored a month ago
818
struct ChargeCheckpointSummary: Identifiable, Hashable {
819
    let id: UUID
820
    let sessionID: UUID
821
    let chargedDeviceID: UUID
Bogdan Timofte authored a month ago
822
    let powerbankID: UUID?
823
    let batteryBarsValue: Int
Bogdan Timofte authored a month ago
824
    let timestamp: Date
825
    let batteryPercent: Double
826
    let measuredEnergyWh: Double
827
    let currentAmps: Double
828
    let voltageVolts: Double?
829
    let label: String?
Bogdan Timofte authored a month ago
830

            
831
    var flag: ChargeCheckpointFlag {
832
        ChargeCheckpointFlag.fromStoredLabel(label)
833
    }
Bogdan Timofte authored a month ago
834

            
835
    var subject: CheckpointSubject {
836
        powerbankID == nil ? .chargedDevice : .powerbank
837
    }
Bogdan Timofte authored a month ago
838
}
839

            
840
enum ChargeCheckpointFlag: String, CaseIterable {
841
    case initial
842
    case intermediate
843
    case final
844

            
845
    var title: String {
846
        switch self {
847
        case .initial:
848
            return "Initial"
849
        case .intermediate:
850
            return "Intermediate"
851
        case .final:
852
            return "Final"
853
        }
854
    }
855

            
856
    var anchorDescription: String {
857
        switch self {
858
        case .initial:
859
            return "initial checkpoint"
860
        case .intermediate:
861
            return "intermediate checkpoint"
862
        case .final:
863
            return "final checkpoint"
864
        }
865
    }
866

            
867
    static func fromStoredLabel(_ label: String?) -> ChargeCheckpointFlag {
868
        let normalized = label?
869
            .trimmingCharacters(in: .whitespacesAndNewlines)
870
            .lowercased()
871

            
872
        switch normalized {
873
        case "initial", "start":
874
            return .initial
875
        case "final", "end":
876
            return .final
877
        case "intermediate", nil, "":
878
            return .intermediate
879
        default:
880
            return .intermediate
881
        }
882
    }
Bogdan Timofte authored a month ago
883
}
884

            
885
struct ChargeSessionSampleSummary: Identifiable, Hashable {
886
    let sessionID: UUID
887
    let chargedDeviceID: UUID
888
    let bucketIndex: Int
889
    let timestamp: Date
890
    let averageCurrentAmps: Double
891
    let averageVoltageVolts: Double?
892
    let averagePowerWatts: Double
893
    let measuredEnergyWh: Double
Bogdan Timofte authored a month ago
894
    let estimatedBatteryPercent: Double?
Bogdan Timofte authored a month ago
895
    let sampleCount: Int
896

            
897
    var id: String {
898
        "\(sessionID.uuidString)-\(bucketIndex)"
899
    }
900
}
901

            
902
struct ChargeSessionSummary: Identifiable, Hashable {
903
    let id: UUID
904
    let chargedDeviceID: UUID
Bogdan Timofte authored a month ago
905
    let chargedPowerbankID: UUID?
Bogdan Timofte authored a month ago
906
    let chargerID: UUID?
Bogdan Timofte authored a month ago
907
    let sourcePowerbankID: UUID?
Bogdan Timofte authored a month ago
908
    let meterMACAddress: String?
909
    let meterName: String?
910
    let meterModel: String?
911
    let startedAt: Date
912
    let endedAt: Date?
913
    let lastObservedAt: Date
Bogdan Timofte authored a month ago
914
    let pausedAt: Date?
Bogdan Timofte authored a month ago
915
    let status: ChargeSessionStatus
916
    let sourceMode: ChargeSessionSourceMode
917
    let chargingTransportMode: ChargingTransportMode
Bogdan Timofte authored a month ago
918
    let chargingStateMode: ChargingStateMode
919
    let autoStopEnabled: Bool
Bogdan Timofte authored a month ago
920
    let measuredEnergyWh: Double
921
    let effectiveBatteryEnergyWh: Double?
Bogdan Timofte authored a month ago
922
    let meterEnergyBaselineWh: Double?
Bogdan Timofte authored a month ago
923
    let meterDurationBaselineSeconds: Double?
924
    let meterLastDurationSeconds: Double?
Bogdan Timofte authored a month ago
925
    let minimumObservedCurrentAmps: Double?
926
    let maximumObservedCurrentAmps: Double?
927
    let maximumObservedPowerWatts: Double?
928
    let maximumObservedVoltageVolts: Double?
Bogdan Timofte authored a month ago
929
    let hasObservedChargeFlow: Bool
Bogdan Timofte authored a month ago
930
    let selectedSourceVoltageVolts: Double?
931
    let completionCurrentAmps: Double?
932
    let stopThresholdAmps: Double
933
    let startBatteryPercent: Double?
934
    let endBatteryPercent: Double?
935
    let capacityEstimateWh: Double?
936
    let wirelessEfficiencyFactor: Double?
937
    let usesEstimatedWirelessEfficiency: Bool
938
    let shouldWarnAboutLowWirelessEfficiency: Bool
939
    let supportsChargingWhileOff: Bool
940
    let usedOfflineMeterCounters: Bool
941
    let targetBatteryPercent: Double?
942
    let targetBatteryAlertTriggeredAt: Date?
943
    let requiresCompletionConfirmation: Bool
944
    let completionConfirmationRequestedAt: Date?
945
    let completionContradictionPercent: Double?
946
    let selectedDataGroup: UInt8?
Bogdan Timofte authored a month ago
947
    let trimStart: Date?
948
    let trimEnd: Date?
Bogdan Timofte authored a month ago
949
    let wasConflictHealed: Bool
Bogdan Timofte authored a month ago
950
    let checkpoints: [ChargeCheckpointSummary]
951
    let aggregatedSamples: [ChargeSessionSampleSummary]
952

            
Bogdan Timofte authored a month ago
953
    var effectiveTrimStart: Date { trimStart ?? startedAt }
954
    var effectiveTrimEnd: Date { trimEnd ?? (endedAt ?? lastObservedAt) }
955
    var isTrimmed: Bool { trimStart != nil || trimEnd != nil }
956
    var effectiveTimeRange: ClosedRange<Date> {
957
        let start = effectiveTrimStart
958
        let end = max(effectiveTrimEnd, start)
959
        return start...end
960
    }
961
    var displayedAggregatedSamples: [ChargeSessionSampleSummary] {
962
        guard isTrimmed else { return aggregatedSamples }
963
        let range = effectiveTimeRange
964
        return aggregatedSamples.filter { range.contains($0.timestamp) }
965
    }
966

            
Bogdan Timofte authored a month ago
967
    var sessionKind: ChargeSessionKind {
968
        ChargeSessionKind(
969
            chargingTransportMode: chargingTransportMode,
970
            chargingStateMode: chargingStateMode
971
        )
972
    }
973

            
Bogdan Timofte authored a month ago
974
    /// Generalized source slot. `none` when no source is tracked, `charger(id)` for the existing
975
    /// charger flow, `powerbank(id)` when a powerbank is supplying power for this session.
976
    var source: ChargeSessionSource {
977
        if let sourcePowerbankID {
978
            return .powerbank(sourcePowerbankID)
979
        }
980
        if let chargerID {
981
            return .charger(chargerID)
982
        }
983
        return .none
984
    }
985

            
986
    var hasPowerbankSubject: Bool { chargedPowerbankID != nil }
987
    var hasPowerbankSource: Bool { sourcePowerbankID != nil }
988

            
Bogdan Timofte authored a month ago
989
    var duration: TimeInterval {
990
        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
991
    }
992

            
Bogdan Timofte authored a month ago
993
    var meterObservedDuration: TimeInterval? {
994
        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
995
            return nil
996
        }
997
        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
998
            return nil
999
        }
1000
        return meterLastDurationSeconds - meterDurationBaselineSeconds
1001
    }
1002

            
1003
    var effectiveDuration: TimeInterval {
Bogdan Timofte authored a month ago
1004
        if isTrimmed {
1005
            return max(effectiveTrimEnd.timeIntervalSince(effectiveTrimStart), 0)
1006
        }
Bogdan Timofte authored a month ago
1007

            
1008
        // Use timestamp-based duration as primary source; only use meter counter if it's consistent
1009
        let timestampDuration = duration
1010

            
1011
        if let meterDuration = meterObservedDuration {
1012
            // Allow 5% tolerance for meter counter vs timestamp calculation
1013
            let tolerance = timestampDuration * 0.05
1014
            let lower = timestampDuration - tolerance
1015
            let upper = timestampDuration + tolerance
1016

            
1017
            // If meter duration is within tolerance range, use it (more precise)
1018
            // Otherwise fall back to timestamp-based duration
1019
            if meterDuration >= lower && meterDuration <= upper {
1020
                return meterDuration
1021
            }
1022
        }
1023

            
1024
        return timestampDuration
Bogdan Timofte authored a month ago
1025
    }
1026

            
Bogdan Timofte authored a month ago
1027
    var effectiveOrMeasuredEnergyWh: Double {
1028
        effectiveBatteryEnergyWh ?? measuredEnergyWh
1029
    }
1030

            
Bogdan Timofte authored a month ago
1031
    var hasSavableChargeData: Bool {
1032
        hasObservedChargeFlow
1033
            || measuredEnergyWh > 0
1034
            || (maximumObservedCurrentAmps ?? 0) > 0
1035
            || (maximumObservedPowerWatts ?? 0) > 0
1036
            || !aggregatedSamples.isEmpty
1037
    }
1038

            
Bogdan Timofte authored a month ago
1039
    var batteryDeltaPercent: Double? {
Bogdan Timofte authored a month ago
1040
        guard let startBatteryPercent, let endBatteryPercent,
1041
              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
Bogdan Timofte authored a month ago
1042
        return endBatteryPercent - startBatteryPercent
1043
    }
Bogdan Timofte authored a month ago
1044

            
1045
    var canAutoStop: Bool {
1046
        autoStopEnabled && stopThresholdAmps > 0
1047
    }
1048

            
1049
    var isPaused: Bool {
1050
        status == .paused
1051
    }
1052

            
1053
    var isOpen: Bool {
1054
        status.isOpen
1055
    }
Bogdan Timofte authored a month ago
1056
}
1057

            
Bogdan Timofte authored a month ago
1058
enum BatteryLevelPredictionBasis: Hashable {
1059
    case capacityEstimate
1060
    case checkpointEnergyMap
1061

            
1062
    var metricLabel: String {
1063
        switch self {
1064
        case .capacityEstimate:
1065
            return "est. capacity"
1066
        case .checkpointEnergyMap:
1067
            return "energy map"
1068
        }
1069
    }
1070

            
1071
    var explanatoryLabel: String {
1072
        switch self {
1073
        case .capacityEstimate:
1074
            return "estimated capacity"
1075
        case .checkpointEnergyMap:
1076
            return "checkpoint energy map"
1077
        }
1078
    }
1079
}
1080

            
Bogdan Timofte authored a month ago
1081
struct BatteryLevelPrediction: Hashable {
1082
    let predictedPercent: Double
Bogdan Timofte authored a month ago
1083
    let estimatedCapacityWh: Double?
1084
    let basis: BatteryLevelPredictionBasis
Bogdan Timofte authored a month ago
1085
    let anchorPercent: Double
1086
    let anchorEnergyWh: Double
1087
    let anchorDescription: String
Bogdan Timofte authored a month ago
1088

            
1089
    func energyWh(forPercent percent: Double) -> Double? {
1090
        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
1091
            return nil
1092
        }
1093

            
1094
        return anchorEnergyWh + ((percent - anchorPercent) / 100) * estimatedCapacityWh
1095
    }
Bogdan Timofte authored a month ago
1096
}
1097

            
Bogdan Timofte authored a month ago
1098
enum BatteryLevelPredictionTuning {
1099
    static let checkpointSettleDuration: TimeInterval = 10 * 60
1100

            
1101
    static func predictedPercent(
1102
        anchorPercent: Double,
1103
        anchorEnergyWh: Double,
1104
        anchorTimestamp: Date,
1105
        anchorIsCheckpoint: Bool,
1106
        effectiveEnergyWh: Double,
1107
        referenceTimestamp: Date,
1108
        estimatedCapacityWh: Double
1109
    ) -> Double {
1110
        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
1111
        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
1112
        let stabilizedGainPercent: Double
1113

            
1114
        if anchorIsCheckpoint {
1115
            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
1116
            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
1117
            stabilizedGainPercent = rawGainPercent * settleProgress
1118
        } else {
1119
            stabilizedGainPercent = rawGainPercent
1120
        }
1121

            
1122
        return min(
1123
            100,
1124
            max(
1125
                0,
1126
                anchorPercent + stabilizedGainPercent
1127
            )
1128
        )
1129
    }
1130
}
1131

            
Bogdan Timofte authored a month ago
1132
struct CapacityTrendPoint: Identifiable, Hashable {
1133
    let sessionID: UUID
1134
    let timestamp: Date
1135
    let capacityWh: Double
1136
    let chargingTransportMode: ChargingTransportMode
1137

            
1138
    var id: UUID { sessionID }
1139
}
1140

            
1141
struct TypicalChargeCurvePoint: Identifiable, Hashable {
1142
    let percentBin: Int
1143
    let averageEnergyWh: Double
1144
    let sampleCount: Int
1145

            
1146
    var id: Int { percentBin }
1147
}
1148

            
Bogdan Timofte authored a month ago
1149
struct ChargerStandbyPowerSample: Identifiable, Hashable, Codable {
1150
    let timestamp: Date
1151
    let powerWatts: Double
1152
    let currentAmps: Double
1153
    let voltageVolts: Double
1154

            
1155
    var id: TimeInterval {
1156
        timestamp.timeIntervalSince1970
1157
    }
1158
}
1159

            
Bogdan Timofte authored a month ago
1160
struct ChargerStandbyPowerDistributionBin: Identifiable, Hashable, Codable {
Bogdan Timofte authored a month ago
1161
    let index: Int
1162
    let lowerBoundWatts: Double
1163
    let upperBoundWatts: Double
1164
    let count: Int
1165
    let relativeFrequency: Double
1166

            
1167
    var id: Int { index }
1168
}
1169

            
Bogdan Timofte authored a month ago
1170
enum HistogramResolution: Int, CaseIterable, Identifiable {
1171
    case x1 = 1
1172
    case x2 = 2
1173
    case x4 = 4
1174

            
1175
    var id: Int { rawValue }
1176

            
1177
    var label: String {
1178
        switch self {
1179
        case .x1: return "1×"
1180
        case .x2: return "2×"
1181
        case .x4: return "4×"
1182
        }
1183
    }
1184
}
1185

            
Bogdan Timofte authored a month ago
1186
struct ChargerStandbyPowerMeasurementStatistics: Hashable {
1187
    let sampleCount: Int
1188
    let observedDuration: TimeInterval
1189
    let averagePowerWatts: Double
1190
    let recentAveragePowerWatts: Double
1191
    let medianPowerWatts: Double
1192
    let minimumPowerWatts: Double
1193
    let maximumPowerWatts: Double
1194
    let standardDeviationPowerWatts: Double
1195
    let coefficientOfVariation: Double
1196
    let averageCurrentAmps: Double
1197
    let averageVoltageVolts: Double
1198
    let stabilityDeltaWatts: Double
1199
    let stabilityToleranceWatts: Double
1200
    let histogram: [ChargerStandbyPowerDistributionBin]
1201

            
1202
    var projectedDailyEnergyWh: Double {
1203
        averagePowerWatts * 24
1204
    }
1205

            
1206
    var projectedWeeklyEnergyWh: Double {
1207
        averagePowerWatts * 24 * 7
1208
    }
1209

            
1210
    var projectedMonthlyEnergyWh: Double {
1211
        averagePowerWatts * 24 * 30
1212
    }
1213

            
1214
    var projectedYearlyEnergyWh: Double {
1215
        averagePowerWatts * 24 * 365
1216
    }
1217

            
1218
    var stabilityDeltaMilliwatts: Double {
1219
        stabilityDeltaWatts * 1000
1220
    }
1221

            
1222
    var isStable: Bool {
1223
        sampleCount >= ChargerStandbyPowerMeasurementAnalyzer.minimumStableSampleCount
1224
        && stabilityDeltaWatts <= stabilityToleranceWatts
1225
    }
1226
}
1227

            
1228
struct ChargerStandbyPowerMeasurementSummary: Identifiable, Hashable, Codable {
1229
    let id: UUID
1230
    let chargerID: UUID
1231
    let meterMACAddress: String
1232
    let meterName: String?
1233
    let meterModel: String?
1234
    let startedAt: Date
1235
    let endedAt: Date
1236
    let sampleCount: Int
1237
    let stabilizedAt: Date?
1238
    let averagePowerWatts: Double
1239
    let recentAveragePowerWatts: Double
1240
    let medianPowerWatts: Double
1241
    let minimumPowerWatts: Double
1242
    let maximumPowerWatts: Double
1243
    let standardDeviationPowerWatts: Double
1244
    let coefficientOfVariation: Double
1245
    let averageCurrentAmps: Double
1246
    let averageVoltageVolts: Double
1247
    let stabilityDeltaWatts: Double
1248
    let stabilityToleranceWatts: Double
Bogdan Timofte authored a month ago
1249
    /// Histogram stored at 4× resolution. Use `histogram(resolution:)` to downsample for display.
1250
    let storedHistogram: [ChargerStandbyPowerDistributionBin]
1251

            
1252
    // MARK: - Codable (with migration from legacy powerSamplesWatts)
1253

            
1254
    private enum CodingKeys: String, CodingKey {
1255
        case id, chargerID, meterMACAddress, meterName, meterModel
1256
        case startedAt, endedAt, sampleCount, stabilizedAt
1257
        case averagePowerWatts, recentAveragePowerWatts, medianPowerWatts
1258
        case minimumPowerWatts, maximumPowerWatts, standardDeviationPowerWatts
1259
        case coefficientOfVariation, averageCurrentAmps, averageVoltageVolts
1260
        case stabilityDeltaWatts, stabilityToleranceWatts
1261
        case storedHistogram
1262
        case powerSamplesWatts // legacy – decode only
1263
    }
1264

            
1265
    init(
1266
        id: UUID, chargerID: UUID, meterMACAddress: String, meterName: String?, meterModel: String?,
1267
        startedAt: Date, endedAt: Date, sampleCount: Int, stabilizedAt: Date?,
1268
        averagePowerWatts: Double, recentAveragePowerWatts: Double, medianPowerWatts: Double,
1269
        minimumPowerWatts: Double, maximumPowerWatts: Double, standardDeviationPowerWatts: Double,
1270
        coefficientOfVariation: Double, averageCurrentAmps: Double, averageVoltageVolts: Double,
1271
        stabilityDeltaWatts: Double, stabilityToleranceWatts: Double,
1272
        storedHistogram: [ChargerStandbyPowerDistributionBin]
1273
    ) {
1274
        self.id = id; self.chargerID = chargerID; self.meterMACAddress = meterMACAddress
1275
        self.meterName = meterName; self.meterModel = meterModel
1276
        self.startedAt = startedAt; self.endedAt = endedAt; self.sampleCount = sampleCount
1277
        self.stabilizedAt = stabilizedAt
1278
        self.averagePowerWatts = averagePowerWatts; self.recentAveragePowerWatts = recentAveragePowerWatts
1279
        self.medianPowerWatts = medianPowerWatts; self.minimumPowerWatts = minimumPowerWatts
1280
        self.maximumPowerWatts = maximumPowerWatts
1281
        self.standardDeviationPowerWatts = standardDeviationPowerWatts
1282
        self.coefficientOfVariation = coefficientOfVariation
1283
        self.averageCurrentAmps = averageCurrentAmps; self.averageVoltageVolts = averageVoltageVolts
1284
        self.stabilityDeltaWatts = stabilityDeltaWatts; self.stabilityToleranceWatts = stabilityToleranceWatts
1285
        self.storedHistogram = storedHistogram
1286
    }
1287

            
1288
    init(from decoder: Decoder) throws {
1289
        let c = try decoder.container(keyedBy: CodingKeys.self)
1290
        id = try c.decode(UUID.self, forKey: .id)
1291
        chargerID = try c.decode(UUID.self, forKey: .chargerID)
1292
        meterMACAddress = try c.decode(String.self, forKey: .meterMACAddress)
1293
        meterName = try c.decodeIfPresent(String.self, forKey: .meterName)
1294
        meterModel = try c.decodeIfPresent(String.self, forKey: .meterModel)
1295
        startedAt = try c.decode(Date.self, forKey: .startedAt)
1296
        endedAt = try c.decode(Date.self, forKey: .endedAt)
1297
        sampleCount = try c.decode(Int.self, forKey: .sampleCount)
1298
        stabilizedAt = try c.decodeIfPresent(Date.self, forKey: .stabilizedAt)
1299
        averagePowerWatts = try c.decode(Double.self, forKey: .averagePowerWatts)
1300
        recentAveragePowerWatts = try c.decode(Double.self, forKey: .recentAveragePowerWatts)
1301
        medianPowerWatts = try c.decode(Double.self, forKey: .medianPowerWatts)
1302
        minimumPowerWatts = try c.decode(Double.self, forKey: .minimumPowerWatts)
1303
        maximumPowerWatts = try c.decode(Double.self, forKey: .maximumPowerWatts)
1304
        standardDeviationPowerWatts = try c.decode(Double.self, forKey: .standardDeviationPowerWatts)
1305
        coefficientOfVariation = try c.decode(Double.self, forKey: .coefficientOfVariation)
1306
        averageCurrentAmps = try c.decode(Double.self, forKey: .averageCurrentAmps)
1307
        averageVoltageVolts = try c.decode(Double.self, forKey: .averageVoltageVolts)
1308
        stabilityDeltaWatts = try c.decode(Double.self, forKey: .stabilityDeltaWatts)
1309
        stabilityToleranceWatts = try c.decode(Double.self, forKey: .stabilityToleranceWatts)
1310

            
1311
        let decodedBins = try? c.decodeIfPresent([ChargerStandbyPowerDistributionBin].self, forKey: .storedHistogram)
1312
        if let decodedBins, !decodedBins.isEmpty {
1313
            storedHistogram = decodedBins
1314
        } else {
1315
            // Migrate from legacy raw samples format
1316
            let samples = (try? c.decodeIfPresent([Double].self, forKey: .powerSamplesWatts)) ?? []
1317
            let base = min(18, max(8, Int(Double(samples.count).squareRoot().rounded())))
1318
            storedHistogram = ChargerStandbyPowerMeasurementAnalyzer.histogram(
1319
                for: samples,
1320
                preferredBinCount: base * HistogramResolution.x4.rawValue
1321
            )
1322
        }
Bogdan Timofte authored a month ago
1323
    }
1324

            
Bogdan Timofte authored a month ago
1325
    func encode(to encoder: Encoder) throws {
1326
        var c = encoder.container(keyedBy: CodingKeys.self)
1327
        try c.encode(id, forKey: .id)
1328
        try c.encode(chargerID, forKey: .chargerID)
1329
        try c.encode(meterMACAddress, forKey: .meterMACAddress)
1330
        try c.encodeIfPresent(meterName, forKey: .meterName)
1331
        try c.encodeIfPresent(meterModel, forKey: .meterModel)
1332
        try c.encode(startedAt, forKey: .startedAt)
1333
        try c.encode(endedAt, forKey: .endedAt)
1334
        try c.encode(sampleCount, forKey: .sampleCount)
1335
        try c.encodeIfPresent(stabilizedAt, forKey: .stabilizedAt)
1336
        try c.encode(averagePowerWatts, forKey: .averagePowerWatts)
1337
        try c.encode(recentAveragePowerWatts, forKey: .recentAveragePowerWatts)
1338
        try c.encode(medianPowerWatts, forKey: .medianPowerWatts)
1339
        try c.encode(minimumPowerWatts, forKey: .minimumPowerWatts)
1340
        try c.encode(maximumPowerWatts, forKey: .maximumPowerWatts)
1341
        try c.encode(standardDeviationPowerWatts, forKey: .standardDeviationPowerWatts)
1342
        try c.encode(coefficientOfVariation, forKey: .coefficientOfVariation)
1343
        try c.encode(averageCurrentAmps, forKey: .averageCurrentAmps)
1344
        try c.encode(averageVoltageVolts, forKey: .averageVoltageVolts)
1345
        try c.encode(stabilityDeltaWatts, forKey: .stabilityDeltaWatts)
1346
        try c.encode(stabilityToleranceWatts, forKey: .stabilityToleranceWatts)
1347
        try c.encode(storedHistogram, forKey: .storedHistogram)
1348
    }
1349

            
1350
    // MARK: - Computed
1351

            
1352
    var duration: TimeInterval { endedAt.timeIntervalSince(startedAt) }
1353
    var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
1354
    var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
1355
    var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
1356
    var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
1357
    var isStable: Bool { stabilizedAt != nil }
1358

            
1359
    /// Returns the histogram downsampled to the requested resolution.
1360
    /// The stored histogram is always at 4× so factor = 4 / resolution.rawValue.
1361
    func histogram(resolution: HistogramResolution = .x4) -> [ChargerStandbyPowerDistributionBin] {
1362
        let factor = HistogramResolution.x4.rawValue / resolution.rawValue
1363
        return ChargerStandbyPowerMeasurementAnalyzer.downsample(storedHistogram, factor: factor)
Bogdan Timofte authored a month ago
1364
    }
1365
}
1366

            
1367
enum ChargerStandbyPowerMeasurementAnalyzer {
1368
    static let minimumStableSampleCount = 45
Bogdan Timofte authored a month ago
1369
    static let recentSampleWindow = 40
1370
    static let minimumStabilityToleranceWatts = 0.010
1371
    static let relativeStabilityTolerance = 0.05
Bogdan Timofte authored a month ago
1372

            
1373
    static func statistics(
1374
        from samples: [ChargerStandbyPowerSample],
1375
        startedAt: Date,
1376
        referenceDate: Date = Date()
1377
    ) -> ChargerStandbyPowerMeasurementStatistics? {
1378
        guard !samples.isEmpty else {
1379
            return nil
1380
        }
1381

            
1382
        let powerValues = samples.map(\.powerWatts).filter(\.isFinite)
1383
        let currentValues = samples.map(\.currentAmps).filter(\.isFinite)
1384
        let voltageValues = samples.map(\.voltageVolts).filter(\.isFinite)
1385

            
1386
        guard powerValues.isEmpty == false else {
1387
            return nil
1388
        }
1389

            
1390
        let averagePower = mean(powerValues)
1391
        let recentWindow = min(recentSampleWindow, max(1, powerValues.count))
1392
        let recentAveragePower = mean(Array(powerValues.suffix(recentWindow)))
1393
        let stabilityDelta = abs(averagePower - recentAveragePower)
1394
        let stabilityTolerance = max(
1395
            minimumStabilityToleranceWatts,
1396
            abs(averagePower) * relativeStabilityTolerance
1397
        )
1398

            
Bogdan Timofte authored a month ago
1399
        let baseBinCount = min(18, max(8, Int(Double(powerValues.count).squareRoot().rounded())))
1400
        let liveHistogram = histogram(for: powerValues, preferredBinCount: baseBinCount * HistogramResolution.x4.rawValue)
1401

            
Bogdan Timofte authored a month ago
1402
        return ChargerStandbyPowerMeasurementStatistics(
1403
            sampleCount: powerValues.count,
1404
            observedDuration: max(referenceDate.timeIntervalSince(startedAt), 0),
1405
            averagePowerWatts: averagePower,
1406
            recentAveragePowerWatts: recentAveragePower,
1407
            medianPowerWatts: median(powerValues),
1408
            minimumPowerWatts: powerValues.min() ?? 0,
1409
            maximumPowerWatts: powerValues.max() ?? 0,
1410
            standardDeviationPowerWatts: standardDeviation(powerValues, mean: averagePower),
1411
            coefficientOfVariation: coefficientOfVariation(powerValues, mean: averagePower),
1412
            averageCurrentAmps: mean(currentValues),
1413
            averageVoltageVolts: mean(voltageValues),
1414
            stabilityDeltaWatts: stabilityDelta,
1415
            stabilityToleranceWatts: stabilityTolerance,
Bogdan Timofte authored a month ago
1416
            histogram: liveHistogram
Bogdan Timofte authored a month ago
1417
        )
1418
    }
1419

            
1420
    static func measurementSummary(
1421
        chargerID: UUID,
1422
        meterMACAddress: String,
1423
        meterName: String?,
1424
        meterModel: String?,
1425
        startedAt: Date,
1426
        endedAt: Date,
1427
        samples: [ChargerStandbyPowerSample],
1428
        stabilizedAt: Date?
1429
    ) -> ChargerStandbyPowerMeasurementSummary? {
1430
        guard let statistics = statistics(from: samples, startedAt: startedAt, referenceDate: endedAt) else {
1431
            return nil
1432
        }
1433

            
1434
        return ChargerStandbyPowerMeasurementSummary(
1435
            id: UUID(),
1436
            chargerID: chargerID,
1437
            meterMACAddress: meterMACAddress,
1438
            meterName: meterName,
1439
            meterModel: meterModel,
1440
            startedAt: startedAt,
1441
            endedAt: endedAt,
1442
            sampleCount: statistics.sampleCount,
1443
            stabilizedAt: stabilizedAt,
1444
            averagePowerWatts: statistics.averagePowerWatts,
1445
            recentAveragePowerWatts: statistics.recentAveragePowerWatts,
1446
            medianPowerWatts: statistics.medianPowerWatts,
1447
            minimumPowerWatts: statistics.minimumPowerWatts,
1448
            maximumPowerWatts: statistics.maximumPowerWatts,
1449
            standardDeviationPowerWatts: statistics.standardDeviationPowerWatts,
1450
            coefficientOfVariation: statistics.coefficientOfVariation,
1451
            averageCurrentAmps: statistics.averageCurrentAmps,
1452
            averageVoltageVolts: statistics.averageVoltageVolts,
1453
            stabilityDeltaWatts: statistics.stabilityDeltaWatts,
1454
            stabilityToleranceWatts: statistics.stabilityToleranceWatts,
Bogdan Timofte authored a month ago
1455
            storedHistogram: statistics.histogram
Bogdan Timofte authored a month ago
1456
        )
1457
    }
1458

            
Bogdan Timofte authored a month ago
1459
    /// Merges consecutive bins in groups of `factor`. Returns the input unchanged when factor ≤ 1.
1460
    static func downsample(
1461
        _ bins: [ChargerStandbyPowerDistributionBin],
1462
        factor: Int
1463
    ) -> [ChargerStandbyPowerDistributionBin] {
1464
        guard factor > 1, !bins.isEmpty else { return bins }
1465
        let totalCount = bins.reduce(0) { $0 + $1.count }
1466
        var result: [ChargerStandbyPowerDistributionBin] = []
1467
        var inputIndex = 0
1468
        var outputIndex = 0
1469
        while inputIndex < bins.count {
1470
            let group = Array(bins[inputIndex..<min(inputIndex + factor, bins.count)])
1471
            let mergedCount = group.reduce(0) { $0 + $1.count }
1472
            let relFreq = totalCount > 0 ? Double(mergedCount) / Double(totalCount) : 0
1473
            result.append(ChargerStandbyPowerDistributionBin(
1474
                index: outputIndex,
1475
                lowerBoundWatts: group.first!.lowerBoundWatts,
1476
                upperBoundWatts: group.last!.upperBoundWatts,
1477
                count: mergedCount,
1478
                relativeFrequency: relFreq
1479
            ))
1480
            inputIndex += factor
1481
            outputIndex += 1
1482
        }
1483
        return result
1484
    }
1485

            
Bogdan Timofte authored a month ago
1486
    static func histogram(for values: [Double], preferredBinCount: Int? = nil) -> [ChargerStandbyPowerDistributionBin] {
1487
        let finiteValues = values.filter(\.isFinite)
1488
        guard finiteValues.isEmpty == false else {
1489
            return []
1490
        }
1491

            
1492
        let minimum = finiteValues.min() ?? 0
1493
        let maximum = finiteValues.max() ?? 0
1494
        let spread = maximum - minimum
1495
        let binCount = preferredBinCount ?? min(18, max(8, Int(Double(finiteValues.count).squareRoot().rounded())))
1496

            
1497
        guard spread > 0 else {
1498
            return [
1499
                ChargerStandbyPowerDistributionBin(
1500
                    index: 0,
1501
                    lowerBoundWatts: minimum,
1502
                    upperBoundWatts: maximum,
1503
                    count: finiteValues.count,
1504
                    relativeFrequency: 1
1505
                )
1506
            ]
1507
        }
1508

            
1509
        let safeBinCount = max(1, binCount)
1510
        let binWidth = spread / Double(safeBinCount)
1511
        var counts = Array(repeating: 0, count: safeBinCount)
1512

            
1513
        for value in finiteValues {
1514
            let normalizedIndex = Int(((value - minimum) / binWidth).rounded(.down))
1515
            let safeIndex = min(max(normalizedIndex, 0), safeBinCount - 1)
1516
            counts[safeIndex] += 1
1517
        }
1518

            
1519
        return counts.enumerated().map { index, count in
1520
            let lowerBound = minimum + (Double(index) * binWidth)
1521
            let upperBound = index == safeBinCount - 1 ? maximum : lowerBound + binWidth
1522

            
1523
            return ChargerStandbyPowerDistributionBin(
1524
                index: index,
1525
                lowerBoundWatts: lowerBound,
1526
                upperBoundWatts: upperBound,
1527
                count: count,
1528
                relativeFrequency: Double(count) / Double(finiteValues.count)
1529
            )
1530
        }
1531
    }
1532

            
1533
    private static func mean(_ values: [Double]) -> Double {
1534
        guard values.isEmpty == false else {
1535
            return 0
1536
        }
1537
        return values.reduce(0, +) / Double(values.count)
1538
    }
1539

            
1540
    private static func median(_ values: [Double]) -> Double {
1541
        guard values.isEmpty == false else {
1542
            return 0
1543
        }
1544

            
1545
        let sorted = values.sorted()
1546
        let middleIndex = sorted.count / 2
1547

            
1548
        if sorted.count.isMultiple(of: 2) {
1549
            return (sorted[middleIndex - 1] + sorted[middleIndex]) / 2
1550
        }
1551

            
1552
        return sorted[middleIndex]
1553
    }
1554

            
1555
    private static func standardDeviation(_ values: [Double], mean: Double) -> Double {
1556
        guard values.count > 1 else {
1557
            return 0
1558
        }
1559

            
1560
        let variance = values.reduce(0) { partialResult, value in
1561
            let delta = value - mean
1562
            return partialResult + (delta * delta)
1563
        } / Double(values.count)
1564

            
1565
        return variance.squareRoot()
1566
    }
1567

            
1568
    private static func coefficientOfVariation(_ values: [Double], mean: Double) -> Double {
1569
        guard abs(mean) > 0.000_001 else {
1570
            return 0
1571
        }
1572

            
1573
        return standardDeviation(values, mean: mean) / abs(mean)
1574
    }
1575
}
1576

            
Bogdan Timofte authored a month ago
1577
struct ChargedDeviceSummary: Identifiable, Hashable {
1578
    let id: UUID
1579
    let qrIdentifier: String
1580
    let name: String
1581
    let deviceClass: ChargedDeviceClass
Bogdan Timofte authored a month ago
1582
    let deviceTemplateID: String?
1583
    let templateDefinition: ChargedDeviceTemplateDefinition?
Bogdan Timofte authored a month ago
1584
    let profileID: String?
1585
    let hasInternalSubject: Bool
Bogdan Timofte authored a month ago
1586
    let supportsChargingWhileOff: Bool
Bogdan Timofte authored a month ago
1587
    let chargingStateAvailability: ChargingStateAvailability
Bogdan Timofte authored a month ago
1588
    let supportsWiredCharging: Bool
1589
    let supportsWirelessCharging: Bool
Bogdan Timofte authored a month ago
1590
    let chargerType: ChargerType?
Bogdan Timofte authored a month ago
1591
    let wirelessChargingProfile: WirelessChargingProfile
Bogdan Timofte authored a month ago
1592
    let configuredCompletionCurrents: [ChargeSessionKind: Double]
1593
    let learnedCompletionCurrents: [ChargeSessionKind: Double]
Bogdan Timofte authored a month ago
1594
    let wirelessChargerEfficiencyFactor: Double?
1595
    let wiredChargeCompletionCurrentAmps: Double?
1596
    let wirelessChargeCompletionCurrentAmps: Double?
1597
    let chargerObservedVoltageSelections: [Double]
1598
    let chargerIdleCurrentAmps: Double?
1599
    let chargerEfficiencyFactor: Double?
1600
    let chargerMaximumPowerWatts: Double?
1601
    let notes: String?
1602
    let minimumCurrentAmps: Double?
1603
    let estimatedBatteryCapacityWh: Double?
1604
    let wiredMinimumCurrentAmps: Double?
1605
    let wirelessMinimumCurrentAmps: Double?
1606
    let wiredEstimatedBatteryCapacityWh: Double?
1607
    let wirelessEstimatedBatteryCapacityWh: Double?
1608
    let createdAt: Date
1609
    let updatedAt: Date
1610
    let sessions: [ChargeSessionSummary]
1611
    let capacityHistory: [CapacityTrendPoint]
1612
    let typicalCurve: [TypicalChargeCurvePoint]
Bogdan Timofte authored a month ago
1613
    let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary]
Bogdan Timofte authored a month ago
1614

            
1615
    var isCharger: Bool {
1616
        deviceClass == .charger
1617
    }
1618

            
Bogdan Timofte authored a month ago
1619
    /// True when the device's active catalog profile is one of the case-style
1620
    /// profiles (AirPods case, charging case, …) — i.e. the editor exposes the
1621
    /// `hasInternalSubject` toggle and the detail UI should surface its state.
1622
    var supportsInternalSubject: Bool {
1623
        guard let profileID,
1624
              let profile = DeviceProfileCatalog.shared.profile(id: profileID) else {
1625
            return false
1626
        }
1627
        return profile.capHasInternalSubject
1628
    }
1629

            
Bogdan Timofte authored a month ago
1630
    var kind: ChargedDeviceKind {
1631
        deviceClass.kind
1632
    }
1633

            
1634
    var identityTitle: String {
Bogdan Timofte authored a month ago
1635
        templateDefinition?.name ?? (isCharger ? kind.title : deviceClass.title)
Bogdan Timofte authored a month ago
1636
    }
1637

            
Bogdan Timofte authored a month ago
1638
    var fallbackIdentitySymbolName: String {
Bogdan Timofte authored a month ago
1639
        isCharger ? kind.symbolName : deviceClass.symbolName
1640
    }
1641

            
Bogdan Timofte authored a month ago
1642
    var identityIcon: ChargedDeviceTemplateIcon {
1643
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
1644
    }
1645

            
1646
    var identitySymbolName: String {
1647
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
1648
    }
1649

            
Bogdan Timofte authored a month ago
1650
    var activeSession: ChargeSessionSummary? {
Bogdan Timofte authored a month ago
1651
        sessions.first(where: \.isOpen)
Bogdan Timofte authored a month ago
1652
    }
1653

            
1654
    var recentCompletedSessions: [ChargeSessionSummary] {
1655
        sessions.filter { $0.status == .completed }
1656
    }
1657

            
1658
    var sessionCount: Int {
1659
        sessions.count
1660
    }
1661

            
Bogdan Timofte authored a month ago
1662
    var latestStandbyPowerMeasurement: ChargerStandbyPowerMeasurementSummary? {
1663
        standbyPowerMeasurements.first
1664
    }
1665

            
Bogdan Timofte authored a month ago
1666
    var supportedChargingModes: [ChargingTransportMode] {
1667
        var modes: [ChargingTransportMode] = []
1668
        if supportsWiredCharging {
1669
            modes.append(.wired)
1670
        }
1671
        if supportsWirelessCharging {
1672
            modes.append(.wireless)
1673
        }
Bogdan Timofte authored a month ago
1674
        return modes
Bogdan Timofte authored a month ago
1675
    }
1676

            
Bogdan Timofte authored a month ago
1677
    var supportedChargingStateModes: [ChargingStateMode] {
1678
        chargingStateAvailability.supportedModes
1679
    }
1680

            
Bogdan Timofte authored a month ago
1681
    var hasMultipleChargingTransports: Bool {
1682
        supportedChargingModes.count > 1
1683
    }
1684

            
1685
    var hasMultipleChargingStateModes: Bool {
1686
        supportedChargingStateModes.count > 1
1687
    }
1688

            
1689
    var showsWirelessProfileDetails: Bool {
1690
        supportsWirelessCharging
1691
            && hasMultipleChargingTransports
1692
            && deviceClass != .watch
1693
    }
1694

            
1695
    var chargingSupportSummary: String {
1696
        switch (supportsWiredCharging, supportsWirelessCharging) {
1697
        case (true, true):
1698
            return "Supports wired and wireless charging."
1699
        case (true, false):
1700
            return "Supports wired charging only."
1701
        case (false, true):
1702
            return "Supports wireless charging only."
1703
        case (false, false):
1704
            return "No charging method configured."
1705
        }
1706
    }
1707

            
Bogdan Timofte authored a month ago
1708
    func defaultChargingStateMode(for chargingTransportMode: ChargingTransportMode) -> ChargingStateMode {
1709
        if let matchingSession = sessions.first(where: {
1710
            $0.status.isOpen && $0.chargingTransportMode == chargingTransportMode
1711
        }) {
1712
            return matchingSession.chargingStateMode
1713
        }
1714
        return chargingStateAvailability.supportedModes.first ?? .on
1715
    }
1716

            
1717
    func sessionKind(
1718
        for chargingTransportMode: ChargingTransportMode,
1719
        chargingStateMode: ChargingStateMode? = nil
1720
    ) -> ChargeSessionKind {
1721
        ChargeSessionKind(
1722
            chargingTransportMode: chargingTransportMode,
1723
            chargingStateMode: chargingStateMode ?? defaultChargingStateMode(for: chargingTransportMode)
1724
        )
1725
    }
1726

            
Bogdan Timofte authored a month ago
1727
    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
1728
        switch chargingTransportMode {
1729
        case .wired:
1730
            return wiredEstimatedBatteryCapacityWh
1731
        case .wireless:
1732
            return wirelessEstimatedBatteryCapacityWh
1733
        }
1734
    }
1735

            
1736
    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
1737
        switch chargingTransportMode {
1738
        case .wired:
1739
            return wiredMinimumCurrentAmps
1740
        case .wireless:
1741
            return wirelessMinimumCurrentAmps
1742
        }
1743
    }
1744

            
Bogdan Timofte authored a month ago
1745
    func shouldShowChargingTransport(_ chargingTransportMode: ChargingTransportMode) -> Bool {
1746
        hasMultipleChargingTransports
1747
            || supportedChargingModes.contains(chargingTransportMode) == false
1748
    }
1749

            
1750
    func shouldShowChargingStateMode(_ chargingStateMode: ChargingStateMode) -> Bool {
1751
        hasMultipleChargingStateModes
1752
            || supportedChargingStateModes.contains(chargingStateMode) == false
1753
    }
1754

            
Bogdan Timofte authored a month ago
1755
    func configuredCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1756
        if let explicitCurrent = configuredCompletionCurrents[sessionKind] {
1757
            return explicitCurrent
1758
        }
1759

            
1760
        switch sessionKind.chargingTransportMode {
Bogdan Timofte authored a month ago
1761
        case .wired:
1762
            return wiredChargeCompletionCurrentAmps
1763
        case .wireless:
1764
            return wirelessChargeCompletionCurrentAmps
1765
        }
1766
    }
1767

            
Bogdan Timofte authored a month ago
1768
    func learnedCompletionCurrentAmps(for sessionKind: ChargeSessionKind) -> Double? {
1769
        if let learnedCurrent = learnedCompletionCurrents[sessionKind] {
1770
            return learnedCurrent
1771
        }
1772

            
1773
        switch sessionKind.chargingTransportMode {
1774
        case .wired:
1775
            return wiredMinimumCurrentAmps ?? minimumCurrentAmps
1776
        case .wireless:
1777
            return wirelessMinimumCurrentAmps ?? minimumCurrentAmps
1778
        }
1779
    }
1780

            
1781
    func resolvedCompletionCurrentAmps(
1782
        for chargingTransportMode: ChargingTransportMode,
1783
        chargingStateMode: ChargingStateMode? = nil
1784
    ) -> Double? {
1785
        let sessionKind = sessionKind(
1786
            for: chargingTransportMode,
1787
            chargingStateMode: chargingStateMode
1788
        )
1789

            
1790
        return configuredCompletionCurrentAmps(for: sessionKind)
1791
            ?? learnedCompletionCurrentAmps(for: sessionKind)
Bogdan Timofte authored a month ago
1792
            ?? minimumCurrentAmps(for: chargingTransportMode)
1793
            ?? minimumCurrentAmps
1794
    }
1795

            
Bogdan Timofte authored a month ago
1796
    func batteryLevelPrediction(
1797
        for session: ChargeSessionSummary,
Bogdan Timofte authored a month ago
1798
        effectiveEnergyWhOverride: Double? = nil,
1799
        referenceTimestamp: Date? = nil
Bogdan Timofte authored a month ago
1800
    ) -> BatteryLevelPrediction? {
Bogdan Timofte authored a month ago
1801
        let estimatedCapacityWh = session.capacityEstimateWh
1802
            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
1803
            ?? estimatedBatteryCapacityWh
1804

            
Bogdan Timofte authored a month ago
1805
        let effectiveEnergyWh = effectiveEnergyWhOverride
1806
            ?? session.effectiveBatteryEnergyWh
1807
            ?? session.measuredEnergyWh
Bogdan Timofte authored a month ago
1808

            
1809
        struct Anchor {
1810
            let percent: Double
1811
            let energyWh: Double
Bogdan Timofte authored a month ago
1812
            let timestamp: Date
Bogdan Timofte authored a month ago
1813
            let description: String
Bogdan Timofte authored a month ago
1814
            let isCheckpoint: Bool
Bogdan Timofte authored a month ago
1815
        }
1816

            
Bogdan Timofte authored a month ago
1817
        func anchorCapacityCandidates(from anchors: [Anchor]) -> [Double] {
1818
            var candidates: [Double] = []
1819

            
1820
            for lowerIndex in anchors.indices {
1821
                for upperIndex in anchors.indices where upperIndex > lowerIndex {
1822
                    let lower = anchors[lowerIndex]
1823
                    let upper = anchors[upperIndex]
1824
                    let percentDelta = upper.percent - lower.percent
1825
                    let energyDelta = upper.energyWh - lower.energyWh
1826

            
1827
                    guard percentDelta >= 3, energyDelta > 0.01 else {
1828
                        continue
1829
                    }
1830

            
1831
                    let capacityWh = energyDelta / (percentDelta / 100)
1832
                    guard capacityWh.isFinite, capacityWh > 0, capacityWh < 1_000 else {
1833
                        continue
1834
                    }
1835

            
1836
                    candidates.append(capacityWh)
1837
                }
1838
            }
1839

            
1840
            return candidates
1841
        }
1842

            
1843
        func inferredCheckpointEnergyMapCapacityWh(from anchors: [Anchor]) -> Double? {
1844
            let candidates = anchorCapacityCandidates(from: anchors)
1845
            guard !candidates.isEmpty else {
1846
                return nil
1847
            }
1848

            
1849
            let sortedCandidates = candidates.sorted()
1850
            return sortedCandidates[sortedCandidates.count / 2]
1851
        }
1852

            
Bogdan Timofte authored a month ago
1853
        var anchors: [Anchor] = []
1854

            
Bogdan Timofte authored a month ago
1855
        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
Bogdan Timofte authored a month ago
1856
            anchors.append(
1857
                Anchor(
1858
                    percent: startBatteryPercent,
1859
                    energyWh: 0,
Bogdan Timofte authored a month ago
1860
                    timestamp: session.effectiveTrimStart,
Bogdan Timofte authored a month ago
1861
                    description: "session start",
1862
                    isCheckpoint: false
Bogdan Timofte authored a month ago
1863
                )
1864
            )
1865
        }
1866

            
1867
        anchors.append(
1868
            contentsOf: session.checkpoints
Bogdan Timofte authored a month ago
1869
                .filter { $0.batteryPercent >= 0 }
Bogdan Timofte authored a month ago
1870
                .map { checkpoint in
Bogdan Timofte authored a month ago
1871
                    Anchor(
Bogdan Timofte authored a month ago
1872
                        percent: checkpoint.batteryPercent,
1873
                        energyWh: checkpoint.measuredEnergyWh,
Bogdan Timofte authored a month ago
1874
                        timestamp: checkpoint.timestamp,
Bogdan Timofte authored a month ago
1875
                        description: checkpoint.flag.anchorDescription,
Bogdan Timofte authored a month ago
1876
                        isCheckpoint: true
Bogdan Timofte authored a month ago
1877
                    )
1878
                }
1879
        )
1880

            
Bogdan Timofte authored a month ago
1881
        let sortedAnchors = anchors.sorted { lhs, rhs in
1882
            if lhs.energyWh != rhs.energyWh {
1883
                return lhs.energyWh < rhs.energyWh
1884
            }
1885
            return lhs.timestamp < rhs.timestamp
1886
        }
1887

            
1888
        guard !sortedAnchors.isEmpty else {
Bogdan Timofte authored a month ago
1889
            return nil
1890
        }
1891

            
Bogdan Timofte authored a month ago
1892
        let canUseCheckpointEnergyMap = deviceClass == .watch || deviceClass == .iphone
1893
        let inferredCapacityWh = estimatedCapacityWh
1894
            ?? (canUseCheckpointEnergyMap ? inferredCheckpointEnergyMapCapacityWh(from: sortedAnchors) : nil)
1895
        let basis: BatteryLevelPredictionBasis = estimatedCapacityWh == nil
1896
            ? .checkpointEnergyMap
1897
            : .capacityEstimate
1898

            
1899
        let lowerAnchor = sortedAnchors.last { $0.energyWh <= effectiveEnergyWh + 0.05 }
1900
        let upperAnchor = sortedAnchors.first { $0.energyWh > effectiveEnergyWh + 0.05 }
1901
        let anchor = lowerAnchor ?? upperAnchor ?? sortedAnchors.first!
Bogdan Timofte authored a month ago
1902

            
1903
        let predictedPercent: Double
1904
        if let lowerAnchor,
1905
           let upperAnchor,
1906
           upperAnchor.energyWh > lowerAnchor.energyWh + 0.05 {
1907
            let interpolationProgress = min(
1908
                max(
1909
                    (effectiveEnergyWh - lowerAnchor.energyWh) /
1910
                    (upperAnchor.energyWh - lowerAnchor.energyWh),
1911
                    0
1912
                ),
1913
                1
1914
            )
1915
            predictedPercent = min(
1916
                max(
1917
                    lowerAnchor.percent +
1918
                    (upperAnchor.percent - lowerAnchor.percent) * interpolationProgress,
1919
                    0
1920
                ),
1921
                100
1922
            )
1923
        } else {
Bogdan Timofte authored a month ago
1924
            guard let inferredCapacityWh, inferredCapacityWh > 0 else {
1925
                return nil
1926
            }
1927

            
Bogdan Timofte authored a month ago
1928
            predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
1929
                anchorPercent: anchor.percent,
1930
                anchorEnergyWh: anchor.energyWh,
1931
                anchorTimestamp: anchor.timestamp,
1932
                anchorIsCheckpoint: anchor.isCheckpoint,
1933
                effectiveEnergyWh: effectiveEnergyWh,
1934
                referenceTimestamp: referenceTimestamp ?? session.lastObservedAt,
Bogdan Timofte authored a month ago
1935
                estimatedCapacityWh: inferredCapacityWh
Bogdan Timofte authored a month ago
1936
            )
1937
        }
Bogdan Timofte authored a month ago
1938

            
1939
        return BatteryLevelPrediction(
1940
            predictedPercent: predictedPercent,
Bogdan Timofte authored a month ago
1941
            estimatedCapacityWh: inferredCapacityWh,
1942
            basis: basis,
Bogdan Timofte authored a month ago
1943
            anchorPercent: anchor.percent,
1944
            anchorEnergyWh: anchor.energyWh,
1945
            anchorDescription: anchor.description
1946
        )
1947
    }
Bogdan Timofte authored a month ago
1948

            
1949
    func withStandbyPowerMeasurements(_ measurements: [ChargerStandbyPowerMeasurementSummary]) -> ChargedDeviceSummary {
1950
        ChargedDeviceSummary(
1951
            id: id,
1952
            qrIdentifier: qrIdentifier,
1953
            name: name,
1954
            deviceClass: deviceClass,
Bogdan Timofte authored a month ago
1955
            deviceTemplateID: deviceTemplateID,
1956
            templateDefinition: templateDefinition,
Bogdan Timofte authored a month ago
1957
            profileID: profileID,
1958
            hasInternalSubject: hasInternalSubject,
Bogdan Timofte authored a month ago
1959
            supportsChargingWhileOff: supportsChargingWhileOff,
1960
            chargingStateAvailability: chargingStateAvailability,
1961
            supportsWiredCharging: supportsWiredCharging,
1962
            supportsWirelessCharging: supportsWirelessCharging,
Bogdan Timofte authored a month ago
1963
            chargerType: chargerType,
Bogdan Timofte authored a month ago
1964
            wirelessChargingProfile: wirelessChargingProfile,
1965
            configuredCompletionCurrents: configuredCompletionCurrents,
1966
            learnedCompletionCurrents: learnedCompletionCurrents,
1967
            wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor,
1968
            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
1969
            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
1970
            chargerObservedVoltageSelections: chargerObservedVoltageSelections,
1971
            chargerIdleCurrentAmps: chargerIdleCurrentAmps,
1972
            chargerEfficiencyFactor: chargerEfficiencyFactor,
1973
            chargerMaximumPowerWatts: chargerMaximumPowerWatts,
1974
            notes: notes,
1975
            minimumCurrentAmps: minimumCurrentAmps,
1976
            estimatedBatteryCapacityWh: estimatedBatteryCapacityWh,
1977
            wiredMinimumCurrentAmps: wiredMinimumCurrentAmps,
1978
            wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps,
1979
            wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh,
1980
            wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh,
1981
            createdAt: createdAt,
1982
            updatedAt: updatedAt,
1983
            sessions: sessions,
1984
            capacityHistory: capacityHistory,
1985
            typicalCurve: typicalCurve,
1986
            standbyPowerMeasurements: measurements
1987
        )
1988
    }
Bogdan Timofte authored a month ago
1989
}
1990

            
1991
struct ChargingMonitorSnapshot {
1992
    let meterMACAddress: String
1993
    let meterName: String
1994
    let meterModel: String
1995
    let observedAt: Date
1996
    let voltageVolts: Double
1997
    let currentAmps: Double
1998
    let powerWatts: Double
1999
    let selectedDataGroup: UInt8?
2000
    let meterChargeCounterAh: Double?
2001
    let meterEnergyCounterWh: Double?
Bogdan Timofte authored a month ago
2002
    let meterRecordingDurationSeconds: TimeInterval?
Bogdan Timofte authored a month ago
2003
    let fallbackStopThresholdAmps: Double
2004
}
Bogdan Timofte authored a month ago
2005

            
2006
// MARK: - Powerbank
2007

            
2008
enum BatteryLevelReporting: String, CaseIterable, Identifiable, Codable {
2009
    case percent
2010
    case bars
2011
    case fullOnly
2012
    case none
2013

            
2014
    var id: String { rawValue }
2015

            
2016
    var title: String {
2017
        switch self {
2018
        case .percent: return "Percent"
2019
        case .bars: return "Bars"
2020
        case .fullOnly: return "Full only"
2021
        case .none: return "Not reported"
2022
        }
2023
    }
2024

            
2025
    var description: String {
2026
        switch self {
2027
        case .percent:
2028
            return "The powerbank reports battery level as 0–100%."
2029
        case .bars:
2030
            return "The powerbank reports battery level as discrete bars (e.g. 4 of 4)."
2031
        case .fullOnly:
2032
            return "The powerbank has a single LED that lights only when charging completes — there is no signal for any partial level."
2033
        case .none:
2034
            return "The powerbank does not report a battery level."
2035
        }
2036
    }
2037

            
2038
    var allowsCheckpoints: Bool {
2039
        self != .none
2040
    }
2041
}
2042

            
2043
enum CheckpointSubject: String, Codable, Hashable {
2044
    case chargedDevice
2045
    case powerbank
2046

            
2047
    var title: String {
2048
        switch self {
2049
        case .chargedDevice: return "Device"
2050
        case .powerbank: return "Powerbank"
2051
        }
2052
    }
2053
}
2054

            
2055
enum ChargeSessionSource: Hashable {
2056
    case none
2057
    case charger(UUID)
2058
    case powerbank(UUID)
2059

            
2060
    var chargerID: UUID? {
2061
        if case .charger(let id) = self { return id }
2062
        return nil
2063
    }
2064

            
2065
    var powerbankID: UUID? {
2066
        if case .powerbank(let id) = self { return id }
2067
        return nil
2068
    }
2069

            
2070
    var isTracked: Bool {
2071
        if case .none = self { return false }
2072
        return true
2073
    }
2074
}
2075

            
2076
struct PowerbankSummary: Identifiable, Hashable {
2077
    let id: UUID
2078
    let qrIdentifier: String
2079
    let name: String
2080
    let deviceTemplateID: String?
2081
    let templateDefinition: ChargedDeviceTemplateDefinition?
2082
    let batteryLevelReporting: BatteryLevelReporting
2083
    let batteryBarsCount: Int
2084
    let estimatedBatteryCapacityWh: Double?
2085
    let apparentCapacityWh: Double?
2086
    let configuredCompletionCurrentAmps: Double?
2087
    let learnedCompletionCurrentAmps: Double?
2088
    let minimumCurrentAmps: Double?
2089
    let sourceObservedVoltageSelections: [Double]
2090
    let sourceVoltageMaxCurrents: [Double: Double]
2091
    let sourceIdleCurrentAmps: Double?
2092
    let sourceMaximumPowerWatts: Double?
2093
    let sourceEfficiencyFactor: Double?
2094
    let notes: String?
2095
    let createdAt: Date
2096
    let updatedAt: Date
2097
    let sessionsAsSubject: [ChargeSessionSummary]
2098
    let sessionsAsSource: [ChargeSessionSummary]
2099

            
2100
    var fallbackIdentitySymbolName: String { "battery.100.bolt" }
2101

            
2102
    var identityIcon: ChargedDeviceTemplateIcon {
2103
        templateDefinition?.icon ?? .systemSymbol(fallbackIdentitySymbolName)
2104
    }
2105

            
2106
    var identitySymbolName: String {
2107
        identityIcon.resolvedSystemSymbolName(fallbackSystemName: fallbackIdentitySymbolName)
2108
    }
2109

            
2110
    var identityTitle: String {
2111
        templateDefinition?.name ?? "Powerbank"
2112
    }
2113

            
2114
    /// Open session in which this powerbank participates as either subject or source.
2115
    var openSession: ChargeSessionSummary? {
2116
        sessionsAsSubject.first(where: \.isOpen)
2117
            ?? sessionsAsSource.first(where: \.isOpen)
2118
    }
2119

            
2120
    var totalDeliveredEnergyWh: Double {
2121
        sessionsAsSource.reduce(0) { $0 + $1.measuredEnergyWh }
2122
    }
2123

            
2124
    var totalReceivedEnergyWh: Double {
2125
        sessionsAsSubject.reduce(0) { $0 + $1.measuredEnergyWh }
2126
    }
2127
}