Showing 28 changed files with 1792 additions and 837 deletions
+3 -1
USB Meter.xcodeproj/project.pbxproj
@@ -130,6 +130,7 @@
130 130
 		C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 7.xcdatamodel"; sourceTree = "<group>"; };
131 131
 		C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
132 132
 		C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
133
+		C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 10.xcdatamodel"; sourceTree = "<group>"; };
133 134
 		430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
134 135
 		4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
135 136
 		4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
@@ -829,8 +830,9 @@
829 830
 				C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */,
830 831
 				C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */,
831 832
 				C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */,
833
+				C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */,
832 834
 			);
833
-			currentVersion = C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */;
835
+			currentVersion = C10000223C8E4A7A00A10022 /* USB_Meter 10.xcdatamodel */;
834 836
 			path = CKModel.xcdatamodeld;
835 837
 			sourceTree = "<group>";
836 838
 			versionGroupType = wrapper.xcdatamodel;
+1 -1
USB Meter/AppDelegate.swift
@@ -233,7 +233,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
233 233
         }
234 234
 
235 235
         container.viewContext.automaticallyMergesChangesFromParent = true
236
-        container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
236
+        container.viewContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
237 237
         return container
238 238
     }()
239 239
 
+152 -25
USB Meter/Model/AppData.swift
@@ -12,6 +12,15 @@ import CoreBluetooth
12 12
 import CoreData
13 13
 import UserNotifications
14 14
 
15
+struct BatteryCheckpointPlausibilityWarning: Identifiable, Hashable {
16
+    let title: String
17
+    let message: String
18
+
19
+    var id: String {
20
+        "\(title)\n\(message)"
21
+    }
22
+}
23
+
15 24
 final class AppData : ObservableObject {
16 25
     struct MeterSummary: Identifiable {
17 26
         let macAddress: String
@@ -77,7 +86,7 @@ final class AppData : ObservableObject {
77 86
         }
78 87
 
79 88
         context.automaticallyMergesChangesFromParent = true
80
-        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
89
+        context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy
81 90
         chargeInsightsStore = ChargeInsightsStore(context: context)
82 91
 
83 92
         chargeInsightsStoreObserver = NotificationCenter.default.publisher(
@@ -143,6 +152,15 @@ final class AppData : ObservableObject {
143 152
         chargedDevices.first(where: { $0.id == id })
144 153
     }
145 154
 
155
+    func chargeSessionSummary(id: UUID) -> ChargeSessionSummary? {
156
+        for chargedDevice in chargedDevices {
157
+            if let session = chargedDevice.sessions.first(where: { $0.id == id }) {
158
+                return session
159
+            }
160
+        }
161
+        return nil
162
+    }
163
+
146 164
     func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
147 165
         let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
148 166
         return chargedDevices.filter { chargedDevice in
@@ -201,25 +219,23 @@ final class AppData : ObservableObject {
201 219
     }
202 220
 
203 221
     @discardableResult
204
-    func createChargedDevice(
222
+    func createDevice(
205 223
         name: String,
206 224
         deviceClass: ChargedDeviceClass,
207 225
         chargingStateAvailability: ChargingStateAvailability,
208 226
         supportsWiredCharging: Bool,
209 227
         supportsWirelessCharging: Bool,
210
-        preferredChargingTransportMode: ChargingTransportMode,
211 228
         wirelessChargingProfile: WirelessChargingProfile,
212 229
         configuredCompletionCurrents: [ChargeSessionKind: Double],
213 230
         notes: String?,
214 231
         meterMACAddress: String?
215 232
     ) -> Bool {
216
-        let didSave = chargeInsightsStore?.createChargedDevice(
233
+        let didSave = chargeInsightsStore?.createDevice(
217 234
             name: name,
218 235
             deviceClass: deviceClass,
219 236
             chargingStateAvailability: chargingStateAvailability,
220 237
             supportsWiredCharging: supportsWiredCharging,
221 238
             supportsWirelessCharging: supportsWirelessCharging,
222
-            preferredChargingTransportMode: preferredChargingTransportMode,
223 239
             wirelessChargingProfile: wirelessChargingProfile,
224 240
             configuredCompletionCurrents: configuredCompletionCurrents,
225 241
             notes: notes,
@@ -234,26 +250,43 @@ final class AppData : ObservableObject {
234 250
     }
235 251
 
236 252
     @discardableResult
237
-    func updateChargedDevice(
253
+    func createCharger(
254
+        name: String,
255
+        notes: String?,
256
+        meterMACAddress: String?
257
+    ) -> Bool {
258
+        let didSave = chargeInsightsStore?.createCharger(
259
+            name: name,
260
+            notes: notes,
261
+            assignTo: meterMACAddress
262
+        ) ?? false
263
+
264
+        if didSave {
265
+            reloadChargedDevices()
266
+        }
267
+
268
+        return didSave
269
+    }
270
+
271
+    @discardableResult
272
+    func updateDevice(
238 273
         id: UUID,
239 274
         name: String,
240 275
         deviceClass: ChargedDeviceClass,
241 276
         chargingStateAvailability: ChargingStateAvailability,
242 277
         supportsWiredCharging: Bool,
243 278
         supportsWirelessCharging: Bool,
244
-        preferredChargingTransportMode: ChargingTransportMode,
245 279
         wirelessChargingProfile: WirelessChargingProfile,
246 280
         configuredCompletionCurrents: [ChargeSessionKind: Double],
247 281
         notes: String?
248 282
     ) -> Bool {
249
-        let didSave = chargeInsightsStore?.updateChargedDevice(
283
+        let didSave = chargeInsightsStore?.updateDevice(
250 284
             id: id,
251 285
             name: name,
252 286
             deviceClass: deviceClass,
253 287
             chargingStateAvailability: chargingStateAvailability,
254 288
             supportsWiredCharging: supportsWiredCharging,
255 289
             supportsWirelessCharging: supportsWirelessCharging,
256
-            preferredChargingTransportMode: preferredChargingTransportMode,
257 290
             wirelessChargingProfile: wirelessChargingProfile,
258 291
             configuredCompletionCurrents: configuredCompletionCurrents,
259 292
             notes: notes
@@ -267,10 +300,15 @@ final class AppData : ObservableObject {
267 300
     }
268 301
 
269 302
     @discardableResult
270
-    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meter: Meter) -> Bool {
271
-        let didSave = chargeInsightsStore?.setChargingTransportMode(
272
-            chargingTransportMode,
273
-            for: meter.btSerial.macAddress.description
303
+    func updateCharger(
304
+        id: UUID,
305
+        name: String,
306
+        notes: String?
307
+    ) -> Bool {
308
+        let didSave = chargeInsightsStore?.updateCharger(
309
+            id: id,
310
+            name: name,
311
+            notes: notes
274 312
         ) ?? false
275 313
 
276 314
         if didSave {
@@ -298,6 +336,16 @@ final class AppData : ObservableObject {
298 336
         return didSave
299 337
     }
300 338
 
339
+    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
340
+        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
341
+            return
342
+        }
343
+        guard activeSession.status == .active else {
344
+            return
345
+        }
346
+        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
347
+    }
348
+
301 349
     @discardableResult
302 350
     func startChargeSession(
303 351
         for meter: Meter,
@@ -306,7 +354,8 @@ final class AppData : ObservableObject {
306 354
         chargingTransportMode: ChargingTransportMode,
307 355
         chargingStateMode: ChargingStateMode,
308 356
         autoStopEnabled: Bool,
309
-        initialBatteryPercent: Double
357
+        initialBatteryPercent: Double?,
358
+        startsFromFlatBattery: Bool
310 359
     ) -> Bool {
311 360
         guard let snapshot = meter.chargingMonitorSnapshot else {
312 361
             return false
@@ -319,10 +368,18 @@ final class AppData : ObservableObject {
319 368
             chargingTransportMode: chargingTransportMode,
320 369
             chargingStateMode: chargingStateMode,
321 370
             autoStopEnabled: autoStopEnabled,
322
-            initialBatteryPercent: initialBatteryPercent
371
+            initialBatteryPercent: initialBatteryPercent,
372
+            startsFromFlatBattery: startsFromFlatBattery
323 373
         ) ?? false
324 374
         if didSave {
325 375
             reloadChargedDevices()
376
+            meter.resetChargeRecordGraph()
377
+            if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description),
378
+               meter.supportsRecordingThreshold,
379
+               activeSession.stopThresholdAmps > 0 {
380
+                meter.recordingTreshold = activeSession.stopThresholdAmps
381
+            }
382
+            restoreChargeMonitoringStateIfNeeded(for: meter)
326 383
         }
327 384
         return didSave
328 385
     }
@@ -404,6 +461,30 @@ final class AppData : ObservableObject {
404 461
         return didSave
405 462
     }
406 463
 
464
+    func batteryCheckpointPlausibilityWarning(
465
+        percent: Double,
466
+        for sessionID: UUID
467
+    ) -> BatteryCheckpointPlausibilityWarning? {
468
+        guard let session = chargeSessionSummary(id: sessionID) else {
469
+            return nil
470
+        }
471
+        return batteryCheckpointPlausibilityWarning(percent: percent, for: session)
472
+    }
473
+
474
+    @discardableResult
475
+    func deleteBatteryCheckpoint(checkpointID: UUID, for sessionID: UUID) -> Bool {
476
+        let didDelete = chargeInsightsStore?.deleteBatteryCheckpoint(
477
+            id: checkpointID,
478
+            from: sessionID
479
+        ) ?? false
480
+
481
+        if didDelete {
482
+            reloadChargedDevices()
483
+        }
484
+
485
+        return didDelete
486
+    }
487
+
407 488
     @discardableResult
408 489
     func flushChargeInsights() -> Bool {
409 490
         let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
@@ -531,16 +612,6 @@ final class AppData : ObservableObject {
531 612
         return didDelete
532 613
     }
533 614
 
534
-    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
535
-        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
536
-            return
537
-        }
538
-        guard activeSession.status == .active else {
539
-            return
540
-        }
541
-        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
542
-    }
543
-
544 615
     var meterSummaries: [MeterSummary] {
545 616
         let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
546 617
         let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
@@ -619,6 +690,62 @@ final class AppData : ObservableObject {
619 690
             }
620 691
         }
621 692
     }
693
+
694
+    private func batteryCheckpointPlausibilityWarning(
695
+        percent: Double,
696
+        for session: ChargeSessionSummary
697
+    ) -> BatteryCheckpointPlausibilityWarning? {
698
+        guard percent.isFinite, percent >= 0, percent <= 100 else {
699
+            return nil
700
+        }
701
+
702
+        let sortedCheckpoints = session.checkpoints.sorted { lhs, rhs in
703
+            if lhs.timestamp != rhs.timestamp {
704
+                return lhs.timestamp < rhs.timestamp
705
+            }
706
+            if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
707
+                return lhs.measuredEnergyWh < rhs.measuredEnergyWh
708
+            }
709
+            return lhs.id.uuidString < rhs.id.uuidString
710
+        }
711
+
712
+        if let lastCheckpoint = sortedCheckpoints.last,
713
+           percent < lastCheckpoint.batteryPercent - 1.5 {
714
+            return BatteryCheckpointPlausibilityWarning(
715
+                title: "Checkpoint Goes Backwards",
716
+                message: "The latest checkpoint is \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))% at \(lastCheckpoint.timestamp.format()). A new value of \(percent.format(decimalDigits: 0))% is unexpectedly lower while the session is still charging."
717
+            )
718
+        }
719
+
720
+        guard let chargedDevice = chargedDeviceSummary(id: session.chargedDeviceID),
721
+              let prediction = chargedDevice.batteryLevelPrediction(for: session)
722
+        else {
723
+            return nil
724
+        }
725
+
726
+        let predictionGap = percent - prediction.predictedPercent
727
+        guard abs(predictionGap) >= 4 else {
728
+            return nil
729
+        }
730
+
731
+        let direction = predictionGap > 0 ? "above" : "below"
732
+        let gapText = abs(predictionGap).format(decimalDigits: 0)
733
+        let predictedText = prediction.predictedPercent.format(decimalDigits: 0)
734
+
735
+        if let lastCheckpoint = sortedCheckpoints.last {
736
+            let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
737
+            let energyDeltaWh = max(effectiveEnergyWh - lastCheckpoint.measuredEnergyWh, 0)
738
+            return BatteryCheckpointPlausibilityWarning(
739
+                title: "Checkpoint Looks Implausible",
740
+                message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Since the last checkpoint at \(lastCheckpoint.batteryPercent.format(decimalDigits: 0))%, only \(energyDeltaWh.format(decimalDigits: 2)) Wh were added."
741
+            )
742
+        }
743
+
744
+        return BatteryCheckpointPlausibilityWarning(
745
+            title: "Checkpoint Looks Implausible",
746
+            message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much."
747
+        )
748
+    }
622 749
 }
623 750
 
624 751
 extension AppData.MeterSummary {
+120 -15
USB Meter/Model/ChargeInsightsModel.swift
@@ -7,6 +7,40 @@
7 7
 
8 8
 import Foundation
9 9
 
10
+enum ChargedDeviceKind: String, Identifiable {
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
+
10 44
 enum ChargedDeviceClass: String, CaseIterable, Identifiable {
11 45
     case iphone
12 46
     case watch
@@ -16,6 +50,14 @@ enum ChargedDeviceClass: String, CaseIterable, Identifiable {
16 50
 
17 51
     var id: String { rawValue }
18 52
 
53
+    static var deviceCases: [ChargedDeviceClass] {
54
+        allCases.filter { $0 != .charger }
55
+    }
56
+
57
+    var kind: ChargedDeviceKind {
58
+        self == .charger ? .charger : .device
59
+    }
60
+
19 61
     var title: String {
20 62
         switch self {
21 63
         case .iphone:
@@ -318,6 +360,8 @@ struct ChargeSessionSummary: Identifiable, Hashable {
318 360
     let measuredEnergyWh: Double
319 361
     let effectiveBatteryEnergyWh: Double?
320 362
     let measuredChargeAh: Double
363
+    let meterEnergyBaselineWh: Double?
364
+    let meterChargeBaselineAh: Double?
321 365
     let minimumObservedCurrentAmps: Double?
322 366
     let maximumObservedCurrentAmps: Double?
323 367
     let maximumObservedPowerWatts: Double?
@@ -358,7 +402,8 @@ struct ChargeSessionSummary: Identifiable, Hashable {
358 402
     }
359 403
 
360 404
     var batteryDeltaPercent: Double? {
361
-        guard let startBatteryPercent, let endBatteryPercent else { return nil }
405
+        guard let startBatteryPercent, let endBatteryPercent,
406
+              startBatteryPercent >= 0, endBatteryPercent >= 0 else { return nil }
362 407
         return endBatteryPercent - startBatteryPercent
363 408
     }
364 409
 
@@ -383,6 +428,40 @@ struct BatteryLevelPrediction: Hashable {
383 428
     let anchorDescription: String
384 429
 }
385 430
 
431
+enum BatteryLevelPredictionTuning {
432
+    static let checkpointSettleDuration: TimeInterval = 10 * 60
433
+
434
+    static func predictedPercent(
435
+        anchorPercent: Double,
436
+        anchorEnergyWh: Double,
437
+        anchorTimestamp: Date,
438
+        anchorIsCheckpoint: Bool,
439
+        effectiveEnergyWh: Double,
440
+        referenceTimestamp: Date,
441
+        estimatedCapacityWh: Double
442
+    ) -> Double {
443
+        let energyDeltaWh = max(effectiveEnergyWh - anchorEnergyWh, 0)
444
+        let rawGainPercent = (energyDeltaWh / estimatedCapacityWh) * 100
445
+        let stabilizedGainPercent: Double
446
+
447
+        if anchorIsCheckpoint {
448
+            let elapsedSinceAnchor = max(referenceTimestamp.timeIntervalSince(anchorTimestamp), 0)
449
+            let settleProgress = min(max(elapsedSinceAnchor / checkpointSettleDuration, 0), 1)
450
+            stabilizedGainPercent = rawGainPercent * settleProgress
451
+        } else {
452
+            stabilizedGainPercent = rawGainPercent
453
+        }
454
+
455
+        return min(
456
+            100,
457
+            max(
458
+                0,
459
+                anchorPercent + stabilizedGainPercent
460
+            )
461
+        )
462
+    }
463
+}
464
+
386 465
 struct CapacityTrendPoint: Identifiable, Hashable {
387 466
     let sessionID: UUID
388 467
     let timestamp: Date
@@ -410,7 +489,6 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
410 489
     let chargingStateAvailability: ChargingStateAvailability
411 490
     let supportsWiredCharging: Bool
412 491
     let supportsWirelessCharging: Bool
413
-    let preferredChargingTransportMode: ChargingTransportMode
414 492
     let wirelessChargingProfile: WirelessChargingProfile
415 493
     let configuredCompletionCurrents: [ChargeSessionKind: Double]
416 494
     let learnedCompletionCurrents: [ChargeSessionKind: Double]
@@ -439,6 +517,18 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
439 517
         deviceClass == .charger
440 518
     }
441 519
 
520
+    var kind: ChargedDeviceKind {
521
+        deviceClass.kind
522
+    }
523
+
524
+    var identityTitle: String {
525
+        isCharger ? kind.title : deviceClass.title
526
+    }
527
+
528
+    var identitySymbolName: String {
529
+        isCharger ? kind.symbolName : deviceClass.symbolName
530
+    }
531
+
442 532
     var activeSession: ChargeSessionSummary? {
443 533
         sessions.first(where: \.isOpen)
444 534
     }
@@ -459,7 +549,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
459 549
         if supportsWirelessCharging {
460 550
             modes.append(.wireless)
461 551
         }
462
-        return modes.isEmpty ? [preferredChargingTransportMode] : modes
552
+        return modes
463 553
     }
464 554
 
465 555
     var supportedChargingStateModes: [ChargingStateMode] {
@@ -544,7 +634,10 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
544 634
             ?? minimumCurrentAmps
545 635
     }
546 636
 
547
-    func batteryLevelPrediction(for session: ChargeSessionSummary) -> BatteryLevelPrediction? {
637
+    func batteryLevelPrediction(
638
+        for session: ChargeSessionSummary,
639
+        effectiveEnergyWhOverride: Double? = nil
640
+    ) -> BatteryLevelPrediction? {
548 641
         let estimatedCapacityWh = session.capacityEstimateWh
549 642
             ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
550 643
             ?? estimatedBatteryCapacityWh
@@ -553,22 +646,28 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
553 646
             return nil
554 647
         }
555 648
 
556
-        let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
649
+        let effectiveEnergyWh = effectiveEnergyWhOverride
650
+            ?? session.effectiveBatteryEnergyWh
651
+            ?? session.measuredEnergyWh
557 652
 
558 653
         struct Anchor {
559 654
             let percent: Double
560 655
             let energyWh: Double
656
+            let timestamp: Date
561 657
             let description: String
658
+            let isCheckpoint: Bool
562 659
         }
563 660
 
564 661
         var anchors: [Anchor] = []
565 662
 
566
-        if let startBatteryPercent = session.startBatteryPercent {
663
+        if let startBatteryPercent = session.startBatteryPercent, startBatteryPercent >= 0 {
567 664
             anchors.append(
568 665
                 Anchor(
569 666
                     percent: startBatteryPercent,
570 667
                     energyWh: 0,
571
-                    description: "session start"
668
+                    timestamp: session.startedAt,
669
+                    description: "session start",
670
+                    isCheckpoint: false
572 671
                 )
573 672
             )
574 673
         }
@@ -581,12 +680,17 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
581 680
                     }
582 681
                     return lhs.timestamp < rhs.timestamp
583 682
                 }
683
+                .filter { checkpoint in
684
+                    checkpoint.batteryPercent >= 0
685
+                }
584 686
                 .map { checkpoint in
585 687
                     let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
586 688
                     return Anchor(
587 689
                         percent: checkpoint.batteryPercent,
588 690
                         energyWh: checkpoint.measuredEnergyWh,
589
-                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint"
691
+                        timestamp: checkpoint.timestamp,
692
+                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint",
693
+                        isCheckpoint: true
590 694
                     )
591 695
                 }
592 696
         )
@@ -597,13 +701,14 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
597 701
 
598 702
         let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
599 703
         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
-            )
704
+        let predictedPercent = BatteryLevelPredictionTuning.predictedPercent(
705
+            anchorPercent: anchor.percent,
706
+            anchorEnergyWh: anchor.energyWh,
707
+            anchorTimestamp: anchor.timestamp,
708
+            anchorIsCheckpoint: anchor.isCheckpoint,
709
+            effectiveEnergyWh: effectiveEnergyWh,
710
+            referenceTimestamp: session.lastObservedAt,
711
+            estimatedCapacityWh: estimatedCapacityWh
607 712
         )
608 713
 
609 714
         return BatteryLevelPrediction(
+297 -149
USB Meter/Model/ChargeInsightsStore.swift
@@ -37,6 +37,7 @@ final class ChargeInsightsStore {
37 37
     private let stopDetectionHoldDuration: TimeInterval = 20
38 38
     private let maximumLiveIntegrationGap: TimeInterval = 20
39 39
     private let activeSessionSaveInterval: TimeInterval = 15
40
+    private let aggregatedSampleSaveInterval: TimeInterval = 5
40 41
     private let counterDecreaseTolerance = 0.002
41 42
     private let completionConfirmationCooldown: TimeInterval = 15 * 60
42 43
     private let pausedSessionTimeout: TimeInterval = 10 * 60
@@ -45,6 +46,7 @@ final class ChargeInsightsStore {
45 46
     private let minimumWirelessEfficiencyFactor = 0.35
46 47
     private let maximumWirelessEfficiencyFactor = 0.95
47 48
     private let lowWirelessEfficiencyThreshold = 0.72
49
+    private let unresolvedFlatBatteryPercent = -1.0
48 50
 
49 51
     init(context: NSManagedObjectContext) {
50 52
         self.context = context
@@ -67,18 +69,18 @@ final class ChargeInsightsStore {
67 69
     }
68 70
 
69 71
     @discardableResult
70
-    func createChargedDevice(
72
+    func createDevice(
71 73
         name: String,
72 74
         deviceClass: ChargedDeviceClass,
73 75
         chargingStateAvailability: ChargingStateAvailability,
74 76
         supportsWiredCharging: Bool,
75 77
         supportsWirelessCharging: Bool,
76
-        preferredChargingTransportMode: ChargingTransportMode,
77 78
         wirelessChargingProfile: WirelessChargingProfile,
78 79
         configuredCompletionCurrents: [ChargeSessionKind: Double],
79 80
         notes: String?,
80 81
         assignTo meterMACAddress: String?
81 82
     ) -> Bool {
83
+        guard deviceClass.kind == .device else { return false }
82 84
         let normalizedName = normalizedText(name)
83 85
         guard !normalizedName.isEmpty else { return false }
84 86
         guard supportsWiredCharging || supportsWirelessCharging else { return false }
@@ -98,14 +100,6 @@ final class ChargeInsightsStore {
98 100
             object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
99 101
             object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
100 102
             object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
101
-            object.setValue(
102
-                resolvedPreferredChargingTransportMode(
103
-                    preferredChargingTransportMode,
104
-                    supportsWiredCharging: supportsWiredCharging,
105
-                    supportsWirelessCharging: supportsWirelessCharging
106
-                ).rawValue,
107
-                forKey: "preferredChargingTransportRawValue"
108
-            )
109 103
             object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
110 104
             object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
111 105
             object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
@@ -121,18 +115,56 @@ final class ChargeInsightsStore {
121 115
     }
122 116
 
123 117
     @discardableResult
124
-    func updateChargedDevice(
118
+    func createCharger(
119
+        name: String,
120
+        notes: String?,
121
+        assignTo meterMACAddress: String?
122
+    ) -> Bool {
123
+        let normalizedName = normalizedText(name)
124
+        guard !normalizedName.isEmpty else { return false }
125
+
126
+        var didSave = false
127
+        context.performAndWait {
128
+            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
129
+                return
130
+            }
131
+
132
+            let object = NSManagedObject(entity: entity, insertInto: context)
133
+            let now = Date()
134
+            object.setValue(UUID().uuidString, forKey: "id")
135
+            object.setValue(normalizedName, forKey: "name")
136
+            object.setValue(ChargedDeviceClass.charger.rawValue, forKey: "deviceClassRawValue")
137
+            object.setValue(ChargingStateAvailability.onOnly.rawValue, forKey: "chargingStateAvailabilityRawValue")
138
+            object.setValue(false, forKey: "supportsChargingWhileOff")
139
+            object.setValue(false, forKey: "supportsWiredCharging")
140
+            object.setValue(true, forKey: "supportsWirelessCharging")
141
+            object.setValue(WirelessChargingProfile.genericQi.rawValue, forKey: "wirelessChargingProfileRawValue")
142
+            object.setValue(encodedCompletionCurrents([:]), forKey: "configuredCompletionCurrentsRawValue")
143
+            object.setValue(nil, forKey: "wiredChargeCompletionCurrentAmps")
144
+            object.setValue(nil, forKey: "wirelessChargeCompletionCurrentAmps")
145
+            object.setValue(normalizedOptionalText(notes), forKey: "notes")
146
+            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
147
+            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
148
+            object.setValue(now, forKey: "createdAt")
149
+            object.setValue(now, forKey: "updatedAt")
150
+            didSave = saveContext()
151
+        }
152
+        return didSave
153
+    }
154
+
155
+    @discardableResult
156
+    func updateDevice(
125 157
         id: UUID,
126 158
         name: String,
127 159
         deviceClass: ChargedDeviceClass,
128 160
         chargingStateAvailability: ChargingStateAvailability,
129 161
         supportsWiredCharging: Bool,
130 162
         supportsWirelessCharging: Bool,
131
-        preferredChargingTransportMode: ChargingTransportMode,
132 163
         wirelessChargingProfile: WirelessChargingProfile,
133 164
         configuredCompletionCurrents: [ChargeSessionKind: Double],
134 165
         notes: String?
135 166
     ) -> Bool {
167
+        guard deviceClass.kind == .device else { return false }
136 168
         let normalizedName = normalizedText(name)
137 169
         guard !normalizedName.isEmpty else { return false }
138 170
         guard supportsWiredCharging || supportsWirelessCharging else { return false }
@@ -142,17 +174,14 @@ final class ChargeInsightsStore {
142 174
             guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
143 175
                 return
144 176
             }
177
+            guard isChargerObject(object) == false else {
178
+                return
179
+            }
145 180
 
146 181
             let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
147 182
             let previousChargingStateAvailability = self.chargingStateAvailability(for: object)
148 183
             let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
149 184
             let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
150
-            let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object)
151
-            let resolvedPreferredTransportMode = resolvedPreferredChargingTransportMode(
152
-                preferredChargingTransportMode,
153
-                supportsWiredCharging: supportsWiredCharging,
154
-                supportsWirelessCharging: supportsWirelessCharging
155
-            )
156 185
             let now = Date()
157 186
 
158 187
             object.setValue(normalizedName, forKey: "name")
@@ -161,7 +190,6 @@ final class ChargeInsightsStore {
161 190
             object.setValue(chargingStateAvailability.supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
162 191
             object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
163 192
             object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
164
-            object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
165 193
             object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
166 194
             object.setValue(encodedCompletionCurrents(configuredCompletionCurrents), forKey: "configuredCompletionCurrentsRawValue")
167 195
             object.setValue(legacyConfiguredCompletionCurrent(for: configuredCompletionCurrents, chargingTransportMode: .wired), forKey: "wiredChargeCompletionCurrentAmps")
@@ -175,7 +203,6 @@ final class ChargeInsightsStore {
175 203
                 || previousChargingStateAvailability != chargingStateAvailability
176 204
                 || previousSupportsWiredCharging != supportsWiredCharging
177 205
                 || previousSupportsWirelessCharging != supportsWirelessCharging
178
-                || previousPreferredChargingTransportMode != resolvedPreferredTransportMode
179 206
 
180 207
             if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
181 208
                 let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
@@ -227,6 +254,33 @@ final class ChargeInsightsStore {
227 254
         return didSave
228 255
     }
229 256
 
257
+    @discardableResult
258
+    func updateCharger(
259
+        id: UUID,
260
+        name: String,
261
+        notes: String?
262
+    ) -> Bool {
263
+        let normalizedName = normalizedText(name)
264
+        guard !normalizedName.isEmpty else { return false }
265
+
266
+        var didSave = false
267
+        context.performAndWait {
268
+            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
269
+                return
270
+            }
271
+            guard isChargerObject(object) else {
272
+                return
273
+            }
274
+
275
+            object.setValue(normalizedName, forKey: "name")
276
+            object.setValue(normalizedOptionalText(notes), forKey: "notes")
277
+            object.setValue(Date(), forKey: "updatedAt")
278
+            didSave = saveContext()
279
+        }
280
+
281
+        return didSave
282
+    }
283
+
230 284
     @discardableResult
231 285
     func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
232 286
         assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
@@ -288,60 +342,6 @@ final class ChargeInsightsStore {
288 342
         return didSave
289 343
     }
290 344
 
291
-    @discardableResult
292
-    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meterMACAddress: String) -> Bool {
293
-        let normalizedMAC = normalizedMACAddress(meterMACAddress)
294
-        guard !normalizedMAC.isEmpty else { return false }
295
-
296
-        var didSave = false
297
-        context.performAndWait {
298
-            let openSession = fetchOpenSessionObject(forMeterMACAddress: normalizedMAC)
299
-            let device = (openSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
300
-                ?? resolvedDeviceObject(for: normalizedMAC)
301
-
302
-            guard let device else {
303
-                return
304
-            }
305
-
306
-            let resolvedMode = resolvedPreferredChargingTransportMode(
307
-                chargingTransportMode,
308
-                supportsWiredCharging: supportsWiredCharging(for: device),
309
-                supportsWirelessCharging: supportsWirelessCharging(for: device)
310
-            )
311
-            let charger = resolvedMode == .wireless ? resolvedChargerObject(for: normalizedMAC) : nil
312
-            guard resolvedMode == .wired || charger != nil else {
313
-                return
314
-            }
315
-
316
-            device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue")
317
-            device.setValue(Date(), forKey: "updatedAt")
318
-
319
-            if let openSession {
320
-                let chargingStateMode = resolvedChargingStateMode(
321
-                    chargingStateMode(for: openSession),
322
-                    availability: chargingStateAvailability(for: device)
323
-                )
324
-                openSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
325
-                openSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
326
-                openSession.setValue(
327
-                    resolvedStopThreshold(
328
-                        for: device,
329
-                        chargingTransportMode: resolvedMode,
330
-                        chargingStateMode: chargingStateMode,
331
-                        charger: charger,
332
-                        fallback: optionalDoubleValue(openSession, key: "stopThresholdAmps")
333
-                    ) ?? 0,
334
-                    forKey: "stopThresholdAmps"
335
-                )
336
-                openSession.setValue(Date(), forKey: "updatedAt")
337
-            }
338
-
339
-            didSave = saveContext()
340
-        }
341
-
342
-        return didSave
343
-    }
344
-
345 345
     @discardableResult
346 346
     func startSession(
347 347
         for snapshot: ChargingMonitorSnapshot,
@@ -350,9 +350,11 @@ final class ChargeInsightsStore {
350 350
         chargingTransportMode: ChargingTransportMode,
351 351
         chargingStateMode: ChargingStateMode,
352 352
         autoStopEnabled: Bool,
353
-        initialBatteryPercent: Double
353
+        initialBatteryPercent: Double?,
354
+        startsFromFlatBattery: Bool
354 355
     ) -> Bool {
355
-        guard initialBatteryPercent.isFinite, initialBatteryPercent >= 0, initialBatteryPercent <= 100 else {
356
+        if let initialBatteryPercent,
357
+           (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
356 358
             return false
357 359
         }
358 360
 
@@ -361,6 +363,9 @@ final class ChargeInsightsStore {
361 363
             guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
362 364
                 return
363 365
             }
366
+            guard isChargerObject(chargedDevice) == false else {
367
+                return
368
+            }
364 369
 
365 370
             guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
366 371
                 return
@@ -378,16 +383,19 @@ final class ChargeInsightsStore {
378 383
             let charger = resolvedChargingTransportMode == .wireless
379 384
                 ? chargerID.flatMap { fetchChargedDeviceObject(id: $0.uuidString) }
380 385
                 : nil
386
+            if let charger, isChargerObject(charger) == false {
387
+                return
388
+            }
381 389
             guard resolvedChargingTransportMode == .wired || charger != nil else {
382 390
                 return
383 391
             }
384
-            let stopThreshold = autoStopEnabled ? resolvedStopThreshold(
392
+            let stopThreshold = resolvedStopThreshold(
385 393
                 for: chargedDevice,
386 394
                 chargingTransportMode: resolvedChargingTransportMode,
387 395
                 chargingStateMode: resolvedChargingStateMode,
388 396
                 charger: charger,
389
-                fallback: nil
390
-            ) : nil
397
+                fallback: snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil
398
+            )
391 399
             guard let session = createSessionObject(
392 400
                 for: chargedDevice,
393 401
                 charger: charger,
@@ -400,13 +408,18 @@ final class ChargeInsightsStore {
400 408
                 return
401 409
             }
402 410
 
403
-            guard insertBatteryCheckpoint(
404
-                percent: initialBatteryPercent,
405
-                label: "Start",
406
-                timestamp: snapshot.observedAt,
407
-                to: session
408
-            ) != nil else {
409
-                return
411
+            if startsFromFlatBattery {
412
+                session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent")
413
+                session.setValue(nil, forKey: "endBatteryPercent")
414
+            } else if let initialBatteryPercent {
415
+                guard insertBatteryCheckpoint(
416
+                    percent: initialBatteryPercent,
417
+                    label: "Start",
418
+                    timestamp: snapshot.observedAt,
419
+                    to: session
420
+                ) != nil else {
421
+                    return
422
+                }
410 423
             }
411 424
             didSave = saveContext()
412 425
         }
@@ -577,6 +590,39 @@ final class ChargeInsightsStore {
577 590
         return didSave
578 591
     }
579 592
 
593
+    @discardableResult
594
+    func deleteBatteryCheckpoint(
595
+        id checkpointID: UUID,
596
+        from sessionID: UUID
597
+    ) -> Bool {
598
+        var didSave = false
599
+        context.performAndWait {
600
+            guard let session = fetchSessionObject(id: sessionID.uuidString),
601
+                  let checkpoint = fetchCheckpointObject(
602
+                    id: checkpointID.uuidString,
603
+                    sessionID: sessionID.uuidString
604
+                  ) else {
605
+                return
606
+            }
607
+
608
+            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
609
+            context.delete(checkpoint)
610
+            refreshCheckpointDerivedValues(for: session)
611
+
612
+            guard saveContext() else {
613
+                return
614
+            }
615
+
616
+            if let chargedDeviceID {
617
+                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
618
+                didSave = saveContext()
619
+            } else {
620
+                didSave = true
621
+            }
622
+        }
623
+        return didSave
624
+    }
625
+
580 626
     @discardableResult
581 627
     func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
582 628
         if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
@@ -769,10 +815,14 @@ final class ChargeInsightsStore {
769 815
             )
770 816
 
771 817
             update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
772
-            updateAggregatedSample(session: session, with: snapshot)
818
+            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
773 819
 
774 820
             let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
775
-            guard saveReason != .none else {
821
+            let shouldPersistAggregatedCurve = aggregatedSample.map {
822
+                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
823
+            } ?? false
824
+
825
+            guard saveReason != .none || shouldPersistAggregatedCurve else {
776 826
                 return
777 827
             }
778 828
 
@@ -855,7 +905,6 @@ final class ChargeInsightsStore {
855 905
                     chargingStateAvailability: chargingStateAvailability(for: device),
856 906
                     supportsWiredCharging: supportsWiredCharging(for: device),
857 907
                     supportsWirelessCharging: supportsWirelessCharging(for: device),
858
-                    preferredChargingTransportMode: preferredChargingTransportMode(for: device),
859 908
                     wirelessChargingProfile: wirelessChargingProfile(for: device),
860 909
                     configuredCompletionCurrents: decodedCompletionCurrents(from: device, key: "configuredCompletionCurrentsRawValue"),
861 910
                     learnedCompletionCurrents: decodedCompletionCurrents(from: device, key: "learnedCompletionCurrentsRawValue"),
@@ -917,11 +966,22 @@ final class ChargeInsightsStore {
917 966
         let normalizedMAC = normalizedMACAddress(meterMACAddress)
918 967
         guard !normalizedMAC.isEmpty else { return nil }
919 968
 
920
-        return fetchChargedDeviceSummaries()
921
-            .flatMap(\.sessions)
922
-            .first(where: {
923
-                $0.status.isOpen && $0.meterMACAddress == normalizedMAC
924
-            })
969
+        var summary: ChargeSessionSummary?
970
+
971
+        context.performAndWait {
972
+            guard let session = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
973
+                  let sessionID = stringValue(session, key: "id") else {
974
+                return
975
+            }
976
+
977
+            summary = makeSessionSummary(
978
+                from: session,
979
+                checkpoints: fetchCheckpointObjects(forSessionID: sessionID),
980
+                samples: fetchSessionSampleObjects(forSessionID: sessionID)
981
+            )
982
+        }
983
+
984
+        return summary
925 985
     }
926 986
 
927 987
     private func createSessionObject(
@@ -951,7 +1011,11 @@ final class ChargeInsightsStore {
951 1011
         session.setValue(now, forKey: "startedAt")
952 1012
         session.setValue(now, forKey: "lastObservedAt")
953 1013
         session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
954
-        session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue")
1014
+        let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil
1015
+        session.setValue(
1016
+            (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue,
1017
+            forKey: "sourceModeRawValue"
1018
+        )
955 1019
         session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
956 1020
         session.setValue(chargingStateMode.rawValue, forKey: "chargingStateRawValue")
957 1021
         session.setValue(autoStopEnabled, forKey: "autoStopEnabled")
@@ -995,7 +1059,6 @@ final class ChargeInsightsStore {
995 1059
         session.setValue(now, forKey: "updatedAt")
996 1060
 
997 1061
         chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
998
-        chargedDevice.setValue(chargingTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
999 1062
         chargedDevice.setValue(now, forKey: "updatedAt")
1000 1063
         return session
1001 1064
     }
@@ -1043,11 +1106,9 @@ final class ChargeInsightsStore {
1043 1106
 
1044 1107
             if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
1045 1108
                 let offlineEnergy = meterEnergyCounterWh - baselineEnergy
1046
-                if offlineEnergy > measuredEnergyWh {
1047
-                    measuredEnergyWh = offlineEnergy
1048
-                }
1109
+                measuredEnergyWh = max(offlineEnergy, 0)
1049 1110
                 usedOfflineMeterCounters = true
1050
-                sourceMode = sourceMode == .live && measuredEnergyWh > 0 ? .blended : .offline
1111
+                sourceMode = .offline
1051 1112
             } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
1052 1113
                 let delta = meterEnergyCounterWh - lastEnergy
1053 1114
                 if delta > 0 {
@@ -1068,9 +1129,7 @@ final class ChargeInsightsStore {
1068 1129
 
1069 1130
             if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
1070 1131
                 let offlineCharge = meterChargeCounterAh - baselineCharge
1071
-                if offlineCharge > measuredChargeAh {
1072
-                    measuredChargeAh = offlineCharge
1073
-                }
1132
+                measuredChargeAh = max(offlineCharge, 0)
1074 1133
                 usedOfflineMeterCounters = true
1075 1134
             } else if let lastCharge, meterChargeCounterAh > lastCharge {
1076 1135
                 let delta = meterChargeCounterAh - lastCharge
@@ -1171,14 +1230,14 @@ final class ChargeInsightsStore {
1171 1230
     private func updateAggregatedSample(
1172 1231
         session: NSManagedObject,
1173 1232
         with snapshot: ChargingMonitorSnapshot
1174
-    ) {
1233
+    ) -> NSManagedObject? {
1175 1234
         guard
1176 1235
             let sessionID = stringValue(session, key: "id"),
1177 1236
             let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1178 1237
             let startedAt = dateValue(session, key: "startedAt"),
1179 1238
             let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
1180 1239
         else {
1181
-            return
1240
+            return nil
1182 1241
         }
1183 1242
 
1184 1243
         let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
@@ -1229,6 +1288,7 @@ final class ChargeInsightsStore {
1229 1288
         sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1230 1289
         sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1231 1290
         sample.setValue(snapshot.observedAt, forKey: "updatedAt")
1291
+        return sample
1232 1292
     }
1233 1293
 
1234 1294
     private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
@@ -1398,11 +1458,21 @@ final class ChargeInsightsStore {
1398 1458
         struct Anchor {
1399 1459
             let percent: Double
1400 1460
             let energyWh: Double
1461
+            let timestamp: Date
1462
+            let isCheckpoint: Bool
1401 1463
         }
1402 1464
 
1403 1465
         var anchors: [Anchor] = []
1404
-        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") {
1405
-            anchors.append(Anchor(percent: startBatteryPercent, energyWh: 0))
1466
+        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1467
+           startBatteryPercent >= 0 {
1468
+            anchors.append(
1469
+                Anchor(
1470
+                    percent: startBatteryPercent,
1471
+                    energyWh: 0,
1472
+                    timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast,
1473
+                    isCheckpoint: false
1474
+                )
1475
+            )
1406 1476
         }
1407 1477
 
1408 1478
         let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
@@ -1413,7 +1483,15 @@ final class ChargeInsightsStore {
1413 1483
                 }
1414 1484
                 return lhs.timestamp < rhs.timestamp
1415 1485
             }
1416
-            .map { Anchor(percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh) }
1486
+            .filter { $0.batteryPercent >= 0 }
1487
+            .map {
1488
+                Anchor(
1489
+                    percent: $0.batteryPercent,
1490
+                    energyWh: $0.measuredEnergyWh,
1491
+                    timestamp: $0.timestamp,
1492
+                    isCheckpoint: true
1493
+                )
1494
+            }
1417 1495
         anchors.append(contentsOf: checkpointAnchors)
1418 1496
 
1419 1497
         guard !anchors.isEmpty else {
@@ -1421,12 +1499,14 @@ final class ChargeInsightsStore {
1421 1499
         }
1422 1500
 
1423 1501
         let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
1424
-        return min(
1425
-            100,
1426
-            max(
1427
-                0,
1428
-                anchor.percent + (((measuredEnergyWh - anchor.energyWh) / estimatedCapacityWh) * 100)
1429
-            )
1502
+        return BatteryLevelPredictionTuning.predictedPercent(
1503
+            anchorPercent: anchor.percent,
1504
+            anchorEnergyWh: anchor.energyWh,
1505
+            anchorTimestamp: anchor.timestamp,
1506
+            anchorIsCheckpoint: anchor.isCheckpoint,
1507
+            effectiveEnergyWh: measuredEnergyWh,
1508
+            referenceTimestamp: dateValue(session, key: "lastObservedAt") ?? anchor.timestamp,
1509
+            estimatedCapacityWh: estimatedCapacityWh
1430 1510
         )
1431 1511
     }
1432 1512
 
@@ -1481,6 +1561,11 @@ final class ChargeInsightsStore {
1481 1561
             return
1482 1562
         }
1483 1563
 
1564
+        guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
1565
+            session.setValue(nil, forKey: "capacityEstimateWh")
1566
+            return
1567
+        }
1568
+
1484 1569
         let percentDelta = endBatteryPercent - startBatteryPercent
1485 1570
         let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1486 1571
 
@@ -1531,16 +1616,38 @@ final class ChargeInsightsStore {
1531 1616
         checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
1532 1617
         checkpoint.setValue(timestamp, forKey: "createdAt")
1533 1618
 
1534
-        if session.value(forKey: "startBatteryPercent") == nil {
1619
+        let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent")
1620
+        if existingStartBatteryPercent == nil || ((existingStartBatteryPercent ?? 0) < 0 && percent > 0) {
1535 1621
             session.setValue(percent, forKey: "startBatteryPercent")
1536 1622
         }
1537
-        session.setValue(percent, forKey: "endBatteryPercent")
1623
+        if (existingStartBatteryPercent ?? 0) >= 0 || percent > 0 {
1624
+            session.setValue(percent, forKey: "endBatteryPercent")
1625
+        }
1538 1626
         session.setValue(timestamp, forKey: "updatedAt")
1539 1627
         updateCapacityEstimate(for: session)
1540 1628
 
1541 1629
         return chargedDeviceID
1542 1630
     }
1543 1631
 
1632
+    private func refreshCheckpointDerivedValues(for session: NSManagedObject) {
1633
+        guard let sessionID = stringValue(session, key: "id") else {
1634
+            return
1635
+        }
1636
+
1637
+        let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID)
1638
+        if let latestCheckpoint = remainingCheckpoints.last {
1639
+            session.setValue(doubleValue(latestCheckpoint, key: "batteryPercent"), forKey: "endBatteryPercent")
1640
+        } else if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1641
+                  startBatteryPercent >= 0 {
1642
+            session.setValue(startBatteryPercent, forKey: "endBatteryPercent")
1643
+        } else {
1644
+            session.setValue(nil, forKey: "endBatteryPercent")
1645
+        }
1646
+
1647
+        session.setValue(Date(), forKey: "updatedAt")
1648
+        updateCapacityEstimate(for: session)
1649
+    }
1650
+
1544 1651
     @discardableResult
1545 1652
     private func addBatteryCheckpoint(
1546 1653
         percent: Double,
@@ -1603,7 +1710,7 @@ final class ChargeInsightsStore {
1603 1710
         }
1604 1711
 
1605 1712
         guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1606
-            ?? ((preferredChargingTransportMode(for: chargedDevice) == .wired)
1713
+            ?? ((fallbackChargingTransportMode(for: chargedDevice) == .wired)
1607 1714
                 ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1608 1715
                 : nil),
1609 1716
               wiredCapacityWh > 0
@@ -1670,7 +1777,7 @@ final class ChargeInsightsStore {
1670 1777
         let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1671 1778
         let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1672 1779
 
1673
-        let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice)
1780
+        let preferredChargingTransportMode = fallbackChargingTransportMode(for: chargedDevice)
1674 1781
         let preferredChargingStateMode = chargingStateAvailability.supportedModes.first ?? .on
1675 1782
         let preferredMinimumCurrent: Double?
1676 1783
         let preferredCapacity: Double?
@@ -1691,19 +1798,19 @@ final class ChargeInsightsStore {
1691 1798
             preferredCapacity = wirelessCapacity ?? wiredCapacity
1692 1799
         }
1693 1800
 
1694
-        chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps")
1695
-        chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps")
1696
-        chargedDevice.setValue(encodedCompletionCurrents(learnedCompletionCurrents), forKey: "learnedCompletionCurrentsRawValue")
1697
-        chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh")
1698
-        chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh")
1699
-        chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor")
1700
-        chargedDevice.setValue(encodedObservedVoltageSelections(chargerObservedVoltages), forKey: "chargerObservedVoltageSelectionsRawValue")
1701
-        chargedDevice.setValue(chargerIdleCurrent, forKey: "chargerIdleCurrentAmps")
1702
-        chargedDevice.setValue(chargerEfficiency, forKey: "chargerEfficiencyFactor")
1703
-        chargedDevice.setValue(chargerMaximumPower, forKey: "chargerMaximumPowerWatts")
1704
-        chargedDevice.setValue(preferredMinimumCurrent, forKey: "minimumCurrentAmps")
1705
-        chargedDevice.setValue(preferredCapacity, forKey: "estimatedBatteryCapacityWh")
1706
-        chargedDevice.setValue(Date(), forKey: "updatedAt")
1801
+        setValue(wiredMinimumCurrent, on: chargedDevice, key: "wiredMinimumCurrentAmps")
1802
+        setValue(wirelessMinimumCurrent, on: chargedDevice, key: "wirelessMinimumCurrentAmps")
1803
+        setValue(encodedCompletionCurrents(learnedCompletionCurrents), on: chargedDevice, key: "learnedCompletionCurrentsRawValue")
1804
+        setValue(wiredCapacity, on: chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1805
+        setValue(wirelessCapacity, on: chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1806
+        setValue(wirelessEfficiency, on: chargedDevice, key: "wirelessChargerEfficiencyFactor")
1807
+        setValue(encodedObservedVoltageSelections(chargerObservedVoltages), on: chargedDevice, key: "chargerObservedVoltageSelectionsRawValue")
1808
+        setValue(chargerIdleCurrent, on: chargedDevice, key: "chargerIdleCurrentAmps")
1809
+        setValue(chargerEfficiency, on: chargedDevice, key: "chargerEfficiencyFactor")
1810
+        setValue(chargerMaximumPower, on: chargedDevice, key: "chargerMaximumPowerWatts")
1811
+        setValue(preferredMinimumCurrent, on: chargedDevice, key: "minimumCurrentAmps")
1812
+        setValue(preferredCapacity, on: chargedDevice, key: "estimatedBatteryCapacityWh")
1813
+        setValue(Date(), on: chargedDevice, key: "updatedAt")
1707 1814
     }
1708 1815
 
1709 1816
     private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
@@ -1836,6 +1943,8 @@ final class ChargeInsightsStore {
1836 1943
             measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1837 1944
             effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1838 1945
             measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1946
+            meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
1947
+            meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
1839 1948
             minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1840 1949
             maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1841 1950
             maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
@@ -1963,6 +2072,13 @@ final class ChargeInsightsStore {
1963 2072
         return (try? context.fetch(request)) ?? []
1964 2073
     }
1965 2074
 
2075
+    private func fetchCheckpointObject(id: String, sessionID: String) -> NSManagedObject? {
2076
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
2077
+        request.predicate = NSPredicate(format: "id == %@ AND sessionID == %@", id, sessionID)
2078
+        request.fetchLimit = 1
2079
+        return (try? context.fetch(request))?.first
2080
+    }
2081
+
1966 2082
     private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1967 2083
         let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
1968 2084
         request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
@@ -2028,11 +2144,14 @@ final class ChargeInsightsStore {
2028 2144
         request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
2029 2145
         let matches = (try? context.fetch(request)) ?? []
2030 2146
         return matches.first { object in
2031
-            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2032
-            return isCharger == expectsChargerClass
2147
+            isChargerObject(object) == expectsChargerClass
2033 2148
         }
2034 2149
     }
2035 2150
 
2151
+    private func isChargerObject(_ object: NSManagedObject) -> Bool {
2152
+        ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
2153
+    }
2154
+
2036 2155
     private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
2037 2156
         let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
2038 2157
         request.predicate = NSPredicate(format: "id == %@", id)
@@ -2090,12 +2209,11 @@ final class ChargeInsightsStore {
2090 2209
         return max(resolvedCurrent, 0.01)
2091 2210
     }
2092 2211
 
2093
-    private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
2212
+    private func fallbackChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
2094 2213
         let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
2095 2214
         let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
2096
-        let persistedMode = chargingTransportModeValue(chargedDevice, key: "preferredChargingTransportRawValue") ?? .wired
2097 2215
         return resolvedPreferredChargingTransportMode(
2098
-            persistedMode,
2216
+            .wired,
2099 2217
             supportsWiredCharging: supportsWiredCharging,
2100 2218
             supportsWirelessCharging: supportsWirelessCharging
2101 2219
         )
@@ -2425,7 +2543,7 @@ final class ChargeInsightsStore {
2425 2543
 
2426 2544
         if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
2427 2545
            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
2428
-            return preferredChargingTransportMode(for: chargedDevice)
2546
+            return fallbackChargingTransportMode(for: chargedDevice)
2429 2547
         }
2430 2548
 
2431 2549
         return .wired
@@ -2477,6 +2595,22 @@ final class ChargeInsightsStore {
2477 2595
         return .none
2478 2596
     }
2479 2597
 
2598
+    private func shouldPersistAggregatedSample(
2599
+        _ sample: NSManagedObject,
2600
+        observedAt: Date
2601
+    ) -> Bool {
2602
+        if sample.isInserted {
2603
+            return true
2604
+        }
2605
+
2606
+        let committedValues = sample.committedValues(forKeys: ["updatedAt"])
2607
+        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
2608
+            ?? dateValue(sample, key: "createdAt")
2609
+            ?? observedAt
2610
+
2611
+        return observedAt.timeIntervalSince(lastPersistedAt) >= aggregatedSampleSaveInterval
2612
+    }
2613
+
2480 2614
     private func generateQRIdentifier() -> String {
2481 2615
         "device:\(UUID().uuidString)"
2482 2616
     }
@@ -2508,59 +2642,73 @@ final class ChargeInsightsStore {
2508 2642
         normalizedText(macAddress).uppercased()
2509 2643
     }
2510 2644
 
2645
+    private func rawValue(_ object: NSManagedObject, key: String) -> Any? {
2646
+        guard object.entity.propertiesByName[key] != nil else {
2647
+            return nil
2648
+        }
2649
+        return object.value(forKey: key)
2650
+    }
2651
+
2652
+    private func setValue(_ value: Any?, on object: NSManagedObject, key: String) {
2653
+        guard object.entity.propertiesByName[key] != nil else {
2654
+            return
2655
+        }
2656
+        object.setValue(value, forKey: key)
2657
+    }
2658
+
2511 2659
     private func stringValue(_ object: NSManagedObject, key: String) -> String? {
2512
-        guard let value = object.value(forKey: key) as? String else { return nil }
2660
+        guard let value = rawValue(object, key: key) as? String else { return nil }
2513 2661
         let normalized = normalizedOptionalText(value)
2514 2662
         return normalized
2515 2663
     }
2516 2664
 
2517 2665
     private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
2518
-        object.value(forKey: key) as? Date
2666
+        rawValue(object, key: key) as? Date
2519 2667
     }
2520 2668
 
2521 2669
     private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
2522
-        if let value = object.value(forKey: key) as? Double {
2670
+        if let value = rawValue(object, key: key) as? Double {
2523 2671
             return value
2524 2672
         }
2525
-        if let value = object.value(forKey: key) as? NSNumber {
2673
+        if let value = rawValue(object, key: key) as? NSNumber {
2526 2674
             return value.doubleValue
2527 2675
         }
2528 2676
         return 0
2529 2677
     }
2530 2678
 
2531 2679
     private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
2532
-        let rawValue = object.value(forKey: key)
2533
-        if rawValue == nil {
2680
+        let value = rawValue(object, key: key)
2681
+        if value == nil {
2534 2682
             return nil
2535 2683
         }
2536 2684
         return doubleValue(object, key: key)
2537 2685
     }
2538 2686
 
2539 2687
     private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
2540
-        if let value = object.value(forKey: key) as? Int16 {
2688
+        if let value = rawValue(object, key: key) as? Int16 {
2541 2689
             return value
2542 2690
         }
2543
-        if let value = object.value(forKey: key) as? NSNumber {
2691
+        if let value = rawValue(object, key: key) as? NSNumber {
2544 2692
             return value.int16Value
2545 2693
         }
2546 2694
         return nil
2547 2695
     }
2548 2696
 
2549 2697
     private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
2550
-        if let value = object.value(forKey: key) as? Int32 {
2698
+        if let value = rawValue(object, key: key) as? Int32 {
2551 2699
             return value
2552 2700
         }
2553
-        if let value = object.value(forKey: key) as? NSNumber {
2701
+        if let value = rawValue(object, key: key) as? NSNumber {
2554 2702
             return value.int32Value
2555 2703
         }
2556 2704
         return nil
2557 2705
     }
2558 2706
 
2559 2707
     private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
2560
-        if let value = object.value(forKey: key) as? Bool {
2708
+        if let value = rawValue(object, key: key) as? Bool {
2561 2709
             return value
2562 2710
         }
2563
-        if let value = object.value(forKey: key) as? NSNumber {
2711
+        if let value = rawValue(object, key: key) as? NSNumber {
2564 2712
             return value.boolValue
2565 2713
         }
2566 2714
         return false
+3 -0
USB Meter/Model/Measurements.swift
@@ -405,6 +405,9 @@ class Measurements : ObservableObject {
405 405
             let delta = value - lastEnergyCounterValue
406 406
             if delta > energyResetEpsilon {
407 407
                 accumulatedEnergyValue += delta
408
+            } else if delta < -energyResetEpsilon {
409
+                energy.addDiscontinuity(timestamp: timestamp)
410
+                accumulatedEnergyValue = 0
408 411
             }
409 412
         }
410 413
 
+16 -6
USB Meter/Model/Meter.swift
@@ -620,7 +620,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
620 620
     }
621 621
 
622 622
     func chargingMonitorSnapshot(at observedAt: Date) -> ChargingMonitorSnapshot? {
623
-        ChargingMonitorSnapshot(
623
+        let usesNativeRecordingCounters = supportsRecordingView
624
+        let nativeChargeCounter = usesNativeRecordingCounters ? recordedAH : nil
625
+        let nativeEnergyCounter = usesNativeRecordingCounters ? recordedWH : nil
626
+
627
+        return ChargingMonitorSnapshot(
624 628
             meterMACAddress: btSerial.macAddress.description,
625 629
             meterName: name,
626 630
             meterModel: deviceModelSummary,
@@ -628,10 +632,10 @@ class Meter : NSObject, ObservableObject, Identifiable {
628 632
             voltageVolts: voltage,
629 633
             currentAmps: current,
630 634
             powerWatts: power,
631
-            selectedDataGroup: currentEnergySample()?.groupID ?? currentChargeSample()?.groupID,
632
-            meterChargeCounterAh: currentChargeSample()?.value,
633
-            meterEnergyCounterWh: currentEnergySample()?.value,
634
-            fallbackStopThresholdAmps: chargeRecordStopThreshold
635
+            selectedDataGroup: usesNativeRecordingCounters ? nil : (currentEnergySample()?.groupID ?? currentChargeSample()?.groupID),
636
+            meterChargeCounterAh: nativeChargeCounter ?? currentChargeSample()?.value,
637
+            meterEnergyCounterWh: nativeEnergyCounter ?? currentEnergySample()?.value,
638
+            fallbackStopThresholdAmps: supportsRecordingThreshold ? recordingTreshold : chargeRecordStopThreshold
635 639
         )
636 640
     }
637 641
 
@@ -788,7 +792,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
788 792
             }
789 793
         }
790 794
         updateChargeRecord(at: dataDumpRequestTimestamp)
791
-        if let energySample = currentEnergySample() {
795
+        if supportsRecordingView {
796
+            measurements.captureEnergyValue(
797
+                timestamp: dataDumpRequestTimestamp,
798
+                value: recordedWH,
799
+                groupID: .max
800
+            )
801
+        } else if let energySample = currentEnergySample() {
792 802
             measurements.captureEnergyValue(
793 803
                 timestamp: dataDumpRequestTimestamp,
794 804
                 value: energySample.value,
+56 -17
USB Meter/Views/ChargedDevices/BatteryCheckpointEditorSheetView.swift
@@ -14,20 +14,40 @@ struct BatteryCheckpointEditorSheetView: View {
14 14
 
15 15
     @State private var batteryPercent = ""
16 16
     @State private var label = ""
17
+    @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning?
18
+
19
+    private var activeSession: ChargeSessionSummary? {
20
+        appData.activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
21
+    }
22
+
23
+    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
24
+        guard let percent = Double(batteryPercent),
25
+              let activeSession else {
26
+            return nil
27
+        }
28
+        return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: activeSession.id)
29
+    }
17 30
 
18 31
     var body: some View {
19 32
         NavigationView {
20 33
             Form {
21
-                Section(header: Text("Checkpoint")) {
34
+                Section(
35
+                    header: ContextInfoHeader(
36
+                        title: "Checkpoint",
37
+                        message: "The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve."
38
+                    )
39
+                ) {
22 40
                     TextField("Battery %", text: $batteryPercent)
23 41
                         .keyboardType(.decimalPad)
24 42
                     TextField("Label (optional)", text: $label)
25 43
                 }
26 44
 
27
-                Section {
28
-                    Text("The checkpoint is stored on the active charge session and later used for capacity estimation and the typical charge curve.")
29
-                        .font(.footnote)
30
-                        .foregroundColor(.secondary)
45
+                if let plausibilityWarning {
46
+                    Section(header: Text(plausibilityWarning.title)) {
47
+                        Text(plausibilityWarning.message)
48
+                            .font(.footnote)
49
+                            .foregroundColor(.orange)
50
+                    }
31 51
                 }
32 52
             }
33 53
             .navigationTitle("Battery Checkpoint")
@@ -40,18 +60,7 @@ struct BatteryCheckpointEditorSheetView: View {
40 60
                 }
41 61
                 ToolbarItem(placement: .confirmationAction) {
42 62
                     Button("Save") {
43
-                        guard let percent = Double(batteryPercent) else {
44
-                            return
45
-                        }
46
-
47
-                        let didSave = appData.addBatteryCheckpoint(
48
-                            percent: percent,
49
-                            label: label,
50
-                            for: meter
51
-                        )
52
-                        if didSave {
53
-                            dismiss()
54
-                        }
63
+                        saveCheckpoint()
55 64
                     }
56 65
                     .disabled(
57 66
                         (Double(batteryPercent) ?? -1) < 0
@@ -62,5 +71,35 @@ struct BatteryCheckpointEditorSheetView: View {
62 71
             }
63 72
         }
64 73
         .navigationViewStyle(StackNavigationViewStyle())
74
+        .alert(item: $confirmationWarning) { warning in
75
+            Alert(
76
+                title: Text(warning.title),
77
+                message: Text(warning.message),
78
+                primaryButton: .destructive(Text("Save Anyway")) {
79
+                    saveCheckpoint(forceOverride: true)
80
+                },
81
+                secondaryButton: .cancel()
82
+            )
83
+        }
84
+    }
85
+
86
+    private func saveCheckpoint(forceOverride: Bool = false) {
87
+        guard let percent = Double(batteryPercent) else {
88
+            return
89
+        }
90
+
91
+        if !forceOverride, let plausibilityWarning {
92
+            confirmationWarning = plausibilityWarning
93
+            return
94
+        }
95
+
96
+        let didSave = appData.addBatteryCheckpoint(
97
+            percent: percent,
98
+            label: label,
99
+            for: meter
100
+        )
101
+        if didSave {
102
+            dismiss()
103
+        }
65 104
     }
66 105
 }
+165 -119
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -80,6 +80,7 @@ struct ChargedDeviceDetailView: View {
80 80
             if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
81 81
                 ChargedDeviceEditorSheetView(
82 82
                     meterMACAddress: nil,
83
+                    kind: chargedDevice.kind,
83 84
                     chargedDevice: chargedDevice
84 85
                 )
85 86
                 .environmentObject(appData)
@@ -136,10 +137,10 @@ struct ChargedDeviceDetailView: View {
136 137
             ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
137 138
 
138 139
             VStack(alignment: .leading, spacing: 10) {
139
-                Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
140
+                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
140 141
                     .font(.title3.weight(.bold))
141 142
 
142
-                Text(chargedDevice.deviceClass.title)
143
+                Text(chargedDevice.identityTitle)
143 144
                     .font(.subheadline.weight(.semibold))
144 145
                     .foregroundColor(.secondary)
145 146
 
@@ -164,103 +165,114 @@ struct ChargedDeviceDetailView: View {
164 165
 
165 166
     private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
166 167
         MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
168
+            if chargedDevice.isCharger {
169
+                chargerInsights(chargedDevice)
170
+            } else {
171
+                deviceInsights(chargedDevice)
172
+            }
173
+
174
+            if let notes = chargedDevice.notes, !notes.isEmpty {
175
+                Divider()
176
+                Text(notes)
177
+                    .font(.footnote)
178
+                    .foregroundColor(.secondary)
179
+                    .frame(maxWidth: .infinity, alignment: .leading)
180
+            }
181
+        }
182
+    }
183
+
184
+    @ViewBuilder
185
+    private func deviceInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
186
+        MeterInfoRowView(
187
+            label: "Charge Modes",
188
+            value: chargedDevice.chargingStateAvailability.title
189
+        )
190
+        MeterInfoRowView(
191
+            label: "Charging Support",
192
+            value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
193
+        )
194
+        if chargedDevice.supportsWirelessCharging {
167 195
             MeterInfoRowView(
168
-                label: "Charge Modes",
169
-                value: chargedDevice.chargingStateAvailability.title
196
+                label: "Wireless Profile",
197
+                value: chargedDevice.wirelessChargingProfile.title
170 198
             )
199
+        }
200
+
201
+        ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
171 202
             MeterInfoRowView(
172
-                label: "Charging Support",
173
-                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
203
+                label: "\(sessionKind.shortTitle) Stop Current",
204
+                value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
174 205
             )
206
+        }
207
+        MeterInfoRowView(
208
+            label: "Estimated Capacity",
209
+            value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
210
+        )
211
+        if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
175 212
             MeterInfoRowView(
176
-                label: "Preferred Session Type",
177
-                value: chargedDevice.preferredChargingTransportMode.title
213
+                label: "Wired Capacity",
214
+                value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
178 215
             )
179
-            if chargedDevice.supportsWirelessCharging {
180
-                MeterInfoRowView(
181
-                    label: "Wireless Profile",
182
-                    value: chargedDevice.wirelessChargingProfile.title
183
-                )
184
-            }
216
+        }
217
+        if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
218
+            MeterInfoRowView(
219
+                label: "Wireless Capacity",
220
+                value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
221
+            )
222
+        }
223
+        if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
224
+            MeterInfoRowView(
225
+                label: "Wireless Efficiency",
226
+                value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
227
+            )
228
+        }
229
+        MeterInfoRowView(
230
+            label: "End-of-Charge Current",
231
+            value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
232
+        )
233
+        MeterInfoRowView(
234
+            label: "Charge Sessions",
235
+            value: "\(chargedDevice.sessionCount)"
236
+        )
237
+    }
185 238
 
186
-            ForEach(completionSessionKinds(for: chargedDevice), id: \.rawValue) { sessionKind in
187
-                MeterInfoRowView(
188
-                    label: "\(sessionKind.shortTitle) Stop Current",
189
-                    value: completionCurrentDescription(for: chargedDevice, sessionKind: sessionKind)
190
-                )
191
-            }
239
+    @ViewBuilder
240
+    private func chargerInsights(_ chargedDevice: ChargedDeviceSummary) -> some View {
241
+        if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
192 242
             MeterInfoRowView(
193
-                label: "Estimated Capacity",
194
-                value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
243
+                label: "Observed Voltages",
244
+                value: chargedDevice.chargerObservedVoltageSelections
245
+                    .map { "\($0.format(decimalDigits: 1)) V" }
246
+                    .joined(separator: ", ")
195 247
             )
196
-            if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
197
-                MeterInfoRowView(
198
-                    label: "Wired Capacity",
199
-                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
200
-                )
201
-            }
202
-            if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
203
-                MeterInfoRowView(
204
-                    label: "Wireless Capacity",
205
-                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
206
-                )
207
-            }
208
-            if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
209
-                MeterInfoRowView(
210
-                    label: "Wireless Efficiency",
211
-                    value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
212
-                )
213
-            }
214
-            if chargedDevice.isCharger {
215
-                Divider()
216
-                if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
217
-                    MeterInfoRowView(
218
-                        label: "Observed Voltages",
219
-                        value: chargedDevice.chargerObservedVoltageSelections
220
-                            .map { "\($0.format(decimalDigits: 1)) V" }
221
-                            .joined(separator: ", ")
222
-                    )
223
-                }
224
-                if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
225
-                    MeterInfoRowView(
226
-                        label: "Idle Current",
227
-                        value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
228
-                    )
229
-                }
230
-                if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
231
-                    MeterInfoRowView(
232
-                        label: "Efficiency",
233
-                        value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
234
-                    )
235
-                }
236
-                if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
237
-                    MeterInfoRowView(
238
-                        label: "Max Power",
239
-                        value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
240
-                    )
241
-                }
242
-                if chargedDevice.chargerIdleCurrentAmps == nil {
243
-                    Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
244
-                        .font(.caption2)
245
-                        .foregroundColor(.orange)
246
-                }
247
-            }
248
+        }
249
+        if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
248 250
             MeterInfoRowView(
249
-                label: "End-of-Charge Current",
250
-                value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
251
+                label: "Idle Current",
252
+                value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
251 253
             )
254
+        }
255
+        if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
252 256
             MeterInfoRowView(
253
-                label: "Charge Sessions",
254
-                value: "\(chargedDevice.sessionCount)"
257
+                label: "Efficiency",
258
+                value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
255 259
             )
260
+        }
261
+        if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
262
+            MeterInfoRowView(
263
+                label: "Max Power",
264
+                value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
265
+            )
266
+        }
267
+        MeterInfoRowView(
268
+            label: "Wireless Sessions",
269
+            value: "\(chargedDevice.sessionCount)"
270
+        )
256 271
 
257
-            if let notes = chargedDevice.notes, !notes.isEmpty {
258
-                Divider()
259
-                Text(notes)
260
-                    .font(.footnote)
261
-                    .foregroundColor(.secondary)
262
-                    .frame(maxWidth: .infinity, alignment: .leading)
263
-            }
272
+        if chargedDevice.chargerIdleCurrentAmps == nil {
273
+            Text("Idle current is missing. Wireless sessions that use this charger can still be recorded, but they cannot learn or auto-apply the wireless stop threshold yet.")
274
+                .font(.caption2)
275
+                .foregroundColor(.orange)
264 276
         }
265 277
     }
266 278
 
@@ -279,7 +291,6 @@ struct ChargedDeviceDetailView: View {
279 291
                abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
280 292
                 MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
281 293
             }
282
-            MeterInfoRowView(label: "Charge", value: "\(activeSession.measuredChargeAh.format(decimalDigits: 3)) Ah")
283 294
             MeterInfoRowView(label: "Auto Stop", value: autoStopDescription(for: activeSession))
284 295
             MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
285 296
             if chargedDevice.isCharger == false,
@@ -455,8 +466,14 @@ struct ChargedDeviceDetailView: View {
455 466
         return VStack(alignment: .leading, spacing: 14) {
456 467
             HStack(alignment: .firstTextBaseline) {
457 468
                 VStack(alignment: .leading, spacing: 4) {
458
-                    Text("Stored Session Curve")
459
-                        .font(.headline)
469
+                    HStack(spacing: 8) {
470
+                        Text("Stored Session Curve")
471
+                            .font(.headline)
472
+                        ContextInfoButton(
473
+                            title: "Stored Session Curve",
474
+                            message: "Database storage and iCloud sync use 300 aggregated points per hour. Active sessions persist their partial aggregated curve continuously, so a reconnect or restart can restore the stored progress."
475
+                        )
476
+                    }
460 477
                     Text(session.status.isOpen ? "Open session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
461 478
                         .font(.caption)
462 479
                         .foregroundColor(.secondary)
@@ -488,9 +505,6 @@ struct ChargedDeviceDetailView: View {
488 505
                 )
489 506
             }
490 507
 
491
-            Text("Database storage and iCloud sync use 300 aggregated points per hour. The live recording session still keeps the original in-memory samples while charging is in progress.")
492
-                .font(.caption)
493
-                .foregroundColor(.secondary)
494 508
         }
495 509
         .frame(maxWidth: .infinity, alignment: .leading)
496 510
         .padding(18)
@@ -524,12 +538,14 @@ struct ChargedDeviceDetailView: View {
524 538
 
525 539
     private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
526 540
         VStack(alignment: .leading, spacing: 12) {
527
-            Text("Charge Sessions")
528
-                .font(.headline)
529
-
530
-            Text("Use these summaries to spot odd sessions quickly before they influence device estimates.")
531
-                .font(.caption)
532
-                .foregroundColor(.secondary)
541
+            HStack(spacing: 8) {
542
+                Text("Charge Sessions")
543
+                    .font(.headline)
544
+                ContextInfoButton(
545
+                    title: "Charge Sessions",
546
+                    message: "Use these summaries to spot odd sessions quickly before they influence device estimates."
547
+                )
548
+            }
533 549
 
534 550
             ForEach(chargedDevice.sessions, id: \.id) { session in
535 551
                 VStack(alignment: .leading, spacing: 6) {
@@ -605,12 +621,6 @@ struct ChargedDeviceDetailView: View {
605 621
                             value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
606 622
                         )
607 623
                     }
608
-                    if let selectedDataGroup = session.selectedDataGroup {
609
-                        MeterInfoRowView(
610
-                            label: "Data Group",
611
-                            value: "#\(selectedDataGroup)"
612
-                        )
613
-                    }
614 624
                     if chargedDevice.isCharger == false,
615 625
                        let chargerID = session.chargerID,
616 626
                        let charger = appData.chargedDeviceSummary(id: chargerID) {
@@ -900,20 +910,35 @@ private struct ChargedDeviceCheckpointEditorSheetView: View {
900 910
 
901 911
     @State private var batteryPercent = ""
902 912
     @State private var label = ""
913
+    @State private var confirmationWarning: BatteryCheckpointPlausibilityWarning?
914
+
915
+    private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
916
+        guard let percent = Double(batteryPercent) else {
917
+            return nil
918
+        }
919
+        return appData.batteryCheckpointPlausibilityWarning(percent: percent, for: sessionID)
920
+    }
903 921
 
904 922
     var body: some View {
905 923
         NavigationView {
906 924
             Form {
907
-                Section(header: Text("Checkpoint")) {
925
+                Section(
926
+                    header: ContextInfoHeader(
927
+                        title: "Checkpoint",
928
+                        message: "The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction."
929
+                    )
930
+                ) {
908 931
                     TextField("Battery %", text: $batteryPercent)
909 932
                         .keyboardType(.decimalPad)
910 933
                     TextField("Label (optional)", text: $label)
911 934
                 }
912 935
 
913
-                Section {
914
-                    Text("The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.")
915
-                        .font(.footnote)
916
-                        .foregroundColor(.secondary)
936
+                if let plausibilityWarning {
937
+                    Section(header: Text(plausibilityWarning.title)) {
938
+                        Text(plausibilityWarning.message)
939
+                            .font(.footnote)
940
+                            .foregroundColor(.orange)
941
+                    }
917 942
                 }
918 943
             }
919 944
             .navigationTitle("Battery Checkpoint")
@@ -927,13 +952,7 @@ private struct ChargedDeviceCheckpointEditorSheetView: View {
927 952
 
928 953
                 ToolbarItem(placement: .confirmationAction) {
929 954
                     Button("Save") {
930
-                        guard let percent = Double(batteryPercent) else {
931
-                            return
932
-                        }
933
-
934
-                        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
935
-                            dismiss()
936
-                        }
955
+                        saveCheckpoint()
937 956
                     }
938 957
                     .disabled(
939 958
                         (Double(batteryPercent) ?? -1) < 0
@@ -943,6 +962,31 @@ private struct ChargedDeviceCheckpointEditorSheetView: View {
943 962
             }
944 963
         }
945 964
         .navigationViewStyle(StackNavigationViewStyle())
965
+        .alert(item: $confirmationWarning) { warning in
966
+            Alert(
967
+                title: Text(warning.title),
968
+                message: Text(warning.message),
969
+                primaryButton: .destructive(Text("Save Anyway")) {
970
+                    saveCheckpoint(forceOverride: true)
971
+                },
972
+                secondaryButton: .cancel()
973
+            )
974
+        }
975
+    }
976
+
977
+    private func saveCheckpoint(forceOverride: Bool = false) {
978
+        guard let percent = Double(batteryPercent) else {
979
+            return
980
+        }
981
+
982
+        if !forceOverride, let plausibilityWarning {
983
+            confirmationWarning = plausibilityWarning
984
+            return
985
+        }
986
+
987
+        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
988
+            dismiss()
989
+        }
946 990
     }
947 991
 }
948 992
 
@@ -964,14 +1008,16 @@ private struct ChargedDeviceTargetNotificationEditorSheetView: View {
964 1008
     var body: some View {
965 1009
         NavigationView {
966 1010
             Form {
967
-                Section(header: Text("Target Level")) {
1011
+                Section(
1012
+                    header: ContextInfoHeader(
1013
+                        title: "Target Level",
1014
+                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
1015
+                    )
1016
+                ) {
968 1017
                     VStack(alignment: .leading, spacing: 12) {
969 1018
                         Text("\(targetPercent.format(decimalDigits: 0))%")
970 1019
                             .font(.title3.weight(.bold))
971 1020
                         Slider(value: $targetPercent, in: 20...100, step: 1)
972
-                        Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
973
-                            .font(.footnote)
974
-                            .foregroundColor(.secondary)
975 1021
                     }
976 1022
                 }
977 1023
             }
+200 -161
USB Meter/Views/ChargedDevices/ChargedDeviceEditorSheetView.swift
@@ -13,132 +13,55 @@ struct ChargedDeviceEditorSheetView: View {
13 13
 
14 14
     let meterMACAddress: String?
15 15
     let chargedDevice: ChargedDeviceSummary?
16
-    let suggestedDeviceClass: ChargedDeviceClass?
16
+    let kind: ChargedDeviceKind
17 17
 
18 18
     @State private var name: String
19 19
     @State private var deviceClass: ChargedDeviceClass
20 20
     @State private var chargingStateAvailability: ChargingStateAvailability
21 21
     @State private var supportsWiredCharging: Bool
22 22
     @State private var supportsWirelessCharging: Bool
23
-    @State private var preferredChargingTransportMode: ChargingTransportMode
24 23
     @State private var wirelessChargingProfile: WirelessChargingProfile
25 24
     @State private var completionCurrentTexts: [ChargeSessionKind: String]
26 25
     @State private var notes: String
27 26
 
28 27
     init(
29 28
         meterMACAddress: String?,
30
-        chargedDevice: ChargedDeviceSummary? = nil,
31
-        suggestedDeviceClass: ChargedDeviceClass? = nil
29
+        kind: ChargedDeviceKind,
30
+        chargedDevice: ChargedDeviceSummary? = nil
32 31
     ) {
33 32
         self.meterMACAddress = meterMACAddress
34 33
         self.chargedDevice = chargedDevice
35
-        self.suggestedDeviceClass = suggestedDeviceClass
36 34
 
37
-        let initialDeviceClass = chargedDevice?.deviceClass ?? suggestedDeviceClass ?? .iphone
35
+        let resolvedKind = chargedDevice?.kind ?? kind
36
+        self.kind = resolvedKind
37
+
38
+        let initialDeviceClass = chargedDevice?.deviceClass ?? (resolvedKind == .charger ? .charger : .iphone)
38 39
         _name = State(initialValue: chargedDevice?.name ?? "")
39 40
         _deviceClass = State(initialValue: initialDeviceClass)
40
-        _chargingStateAvailability = State(initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass))
41
+        _chargingStateAvailability = State(
42
+            initialValue: chargedDevice?.chargingStateAvailability ?? Self.suggestedChargingStateAvailability(for: initialDeviceClass)
43
+        )
41 44
         _supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true)
42 45
         _supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true)
43
-        _preferredChargingTransportMode = State(initialValue: chargedDevice?.preferredChargingTransportMode ?? .wired)
44 46
         _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
45
-        _completionCurrentTexts = State(
46
-            initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice)
47
-        )
47
+        _completionCurrentTexts = State(initialValue: Self.makeCompletionCurrentTexts(for: chargedDevice))
48 48
         _notes = State(initialValue: chargedDevice?.notes ?? "")
49 49
     }
50 50
 
51 51
     var body: some View {
52 52
         NavigationView {
53 53
             Form {
54
-                Section(header: Text("Identity")) {
55
-                    TextField("Name", text: $name)
56
-
57
-                    Picker("Class", selection: $deviceClass) {
58
-                        ForEach(ChargedDeviceClass.allCases) { deviceClass in
59
-                            Label(deviceClass.title, systemImage: deviceClass.symbolName)
60
-                                .tag(deviceClass)
61
-                        }
62
-                    }
63
-
64
-                    if let chargedDevice {
65
-                        Text(chargedDevice.qrIdentifier)
66
-                            .font(.caption.monospaced())
67
-                            .foregroundColor(.secondary)
68
-                            .textSelection(.enabled)
69
-                    }
70
-                }
71
-
72
-                Section(header: Text("Charge Behaviour")) {
73
-                    Picker("Session Modes", selection: $chargingStateAvailability) {
74
-                        ForEach(ChargingStateAvailability.allCases) { availability in
75
-                            Text(availability.title)
76
-                                .tag(availability)
77
-                        }
78
-                    }
79
-
80
-                    Text(chargingStateAvailability.description)
81
-                        .font(.footnote)
82
-                        .foregroundColor(.secondary)
83
-                }
84
-
85
-                Section(header: Text("Charging Support")) {
86
-                    Toggle("Supports wired charging", isOn: $supportsWiredCharging)
87
-                    Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
88
-
89
-                    if supportsWirelessCharging {
90
-                        Picker("Wireless profile", selection: $wirelessChargingProfile) {
91
-                            ForEach(WirelessChargingProfile.allCases) { profile in
92
-                                Text(profile.title)
93
-                                    .tag(profile)
94
-                            }
95
-                        }
96
-
97
-                        Text(wirelessChargingProfile.description)
98
-                            .font(.footnote)
99
-                            .foregroundColor(.secondary)
100
-                    }
101
-
102
-                    if !supportedChargingModes.isEmpty {
103
-                        Picker("Default session type", selection: preferredChargingTransportBinding) {
104
-                            ForEach(supportedChargingModes) { chargingTransportMode in
105
-                                Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
106
-                                    .tag(chargingTransportMode)
107
-                            }
108
-                        }
109
-                    } else {
110
-                        Text("Enable at least one charging method.")
111
-                            .font(.footnote)
112
-                            .foregroundColor(.secondary)
113
-                    }
54
+                identitySection
55
+
56
+                if kind == .device {
57
+                    deviceChargeBehaviourSection
58
+                    deviceChargingSupportSection
59
+                    deviceCompletionSection
60
+                } else {
61
+                    chargerInformationSection
114 62
                 }
115 63
 
116
-                Section(header: Text("Charge Completion")) {
117
-                    if applicableSessionKinds.isEmpty {
118
-                        Text("Enable at least one charging method to configure stop currents.")
119
-                            .font(.footnote)
120
-                            .foregroundColor(.secondary)
121
-                    } else {
122
-                        ForEach(applicableSessionKinds) { sessionKind in
123
-                            VStack(alignment: .leading, spacing: 6) {
124
-                                TextField(
125
-                                    "\(sessionKind.shortTitle) completion current (A)",
126
-                                    text: completionCurrentTextBinding(for: sessionKind)
127
-                                )
128
-                                .keyboardType(.decimalPad)
129
-
130
-                                Text("Leave empty to keep learning this threshold from sessions of the same type.")
131
-                                    .font(.caption)
132
-                                    .foregroundColor(.secondary)
133
-                            }
134
-                            .padding(.vertical, 2)
135
-                        }
136
-                    }
137
-                }
138
-
139
-                Section(header: Text("Notes")) {
140
-                    TextField("Optional notes", text: $notes)
141
-                }
64
+                notesSection
142 65
             }
143 66
             .navigationTitle(editorTitle)
144 67
             .navigationBarTitleDisplayMode(.inline)
@@ -150,71 +73,157 @@ struct ChargedDeviceEditorSheetView: View {
150 73
                 }
151 74
                 ToolbarItem(placement: .confirmationAction) {
152 75
                     Button(saveButtonTitle) {
153
-                        let configuredCompletionCurrents = parsedCompletionCurrents
154
-                        let didSave: Bool
155
-                        if let chargedDevice {
156
-                            didSave = appData.updateChargedDevice(
157
-                                id: chargedDevice.id,
158
-                                name: name,
159
-                                deviceClass: deviceClass,
160
-                                chargingStateAvailability: chargingStateAvailability,
161
-                                supportsWiredCharging: supportsWiredCharging,
162
-                                supportsWirelessCharging: supportsWirelessCharging,
163
-                                preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
164
-                                wirelessChargingProfile: wirelessChargingProfile,
165
-                                configuredCompletionCurrents: configuredCompletionCurrents,
166
-                                notes: notes
167
-                            )
168
-                        } else {
169
-                            didSave = appData.createChargedDevice(
170
-                                name: name,
171
-                                deviceClass: deviceClass,
172
-                                chargingStateAvailability: chargingStateAvailability,
173
-                                supportsWiredCharging: supportsWiredCharging,
174
-                                supportsWirelessCharging: supportsWirelessCharging,
175
-                                preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
176
-                                wirelessChargingProfile: wirelessChargingProfile,
177
-                                configuredCompletionCurrents: configuredCompletionCurrents,
178
-                                notes: notes,
179
-                                meterMACAddress: meterMACAddress
180
-                            )
181
-                        }
182
-
183
-                        if didSave {
184
-                            dismiss()
185
-                        }
76
+                        save()
186 77
                     }
187
-                    .disabled(
188
-                        name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
189
-                            || (!supportsWiredCharging && !supportsWirelessCharging)
190
-                            || hasInvalidCompletionCurrentEntry
191
-                    )
78
+                    .disabled(!canSave)
192 79
                 }
193 80
             }
194 81
         }
195 82
         .navigationViewStyle(StackNavigationViewStyle())
196 83
         .onChange(of: deviceClass) { newValue in
84
+            guard kind == .device else {
85
+                return
86
+            }
197 87
             applySuggestedChargingSupport(for: newValue)
198 88
         }
199 89
         .onAppear {
200
-            guard chargedDevice == nil else {
90
+            guard kind == .device, chargedDevice == nil else {
201 91
                 return
202 92
             }
203 93
             applySuggestedChargingSupport(for: deviceClass)
204 94
         }
205 95
     }
206 96
 
97
+    private var identitySection: some View {
98
+        Section(header: Text("Identity")) {
99
+            TextField(kind == .charger ? "Charger name" : "Name", text: $name)
100
+
101
+            if kind == .device {
102
+                Picker("Class", selection: $deviceClass) {
103
+                    ForEach(ChargedDeviceClass.deviceCases) { deviceClass in
104
+                        Label(deviceClass.title, systemImage: deviceClass.symbolName)
105
+                            .tag(deviceClass)
106
+                    }
107
+                }
108
+            }
109
+
110
+            if let chargedDevice {
111
+                Text(chargedDevice.qrIdentifier)
112
+                    .font(.caption.monospaced())
113
+                    .foregroundColor(.secondary)
114
+                    .textSelection(.enabled)
115
+            }
116
+        }
117
+    }
118
+
119
+    private var deviceChargeBehaviourSection: some View {
120
+        Section(
121
+            header: ContextInfoHeader(
122
+                title: "Charge Behaviour",
123
+                message: "Choose whether sessions for this device are recorded only while it is on, only while it is off, or explicitly in either state."
124
+            )
125
+        ) {
126
+            Picker("Session Modes", selection: $chargingStateAvailability) {
127
+                ForEach(ChargingStateAvailability.allCases) { availability in
128
+                    Text(availability.title)
129
+                        .tag(availability)
130
+                }
131
+            }
132
+        }
133
+    }
134
+
135
+    private var deviceChargingSupportSection: some View {
136
+        Section(
137
+            header: ContextInfoHeader(
138
+                title: "Charging Support",
139
+                message: "Enable the charging methods this device actually supports. Wireless devices can also keep a dedicated profile so learning stays separate."
140
+            )
141
+        ) {
142
+            Toggle("Supports wired charging", isOn: $supportsWiredCharging)
143
+            Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
144
+
145
+            if supportsWirelessCharging {
146
+                Picker("Wireless profile", selection: $wirelessChargingProfile) {
147
+                    ForEach(WirelessChargingProfile.allCases) { profile in
148
+                        Text(profile.title)
149
+                            .tag(profile)
150
+                    }
151
+                }
152
+
153
+            }
154
+
155
+            if supportedChargingModes.isEmpty {
156
+                Text("Enable at least one charging method.")
157
+                    .font(.footnote)
158
+                    .foregroundColor(.secondary)
159
+            }
160
+        }
161
+    }
162
+
163
+    private var deviceCompletionSection: some View {
164
+        Section(
165
+            header: ContextInfoHeader(
166
+                title: "Charge Completion",
167
+                message: "Completion currents can be set per session type. Leave a value empty to keep learning that threshold automatically from sessions of the same type."
168
+            )
169
+        ) {
170
+            if applicableSessionKinds.isEmpty {
171
+                Text("Enable at least one charging method to configure stop currents.")
172
+                    .font(.footnote)
173
+                    .foregroundColor(.secondary)
174
+            } else {
175
+                ForEach(applicableSessionKinds) { sessionKind in
176
+                    VStack(alignment: .leading, spacing: 6) {
177
+                        TextField(
178
+                            "\(sessionKind.shortTitle) completion current (A)",
179
+                            text: completionCurrentTextBinding(for: sessionKind)
180
+                        )
181
+                        .keyboardType(.decimalPad)
182
+                    }
183
+                    .padding(.vertical, 2)
184
+                }
185
+            }
186
+        }
187
+    }
188
+
189
+    private var chargerInformationSection: some View {
190
+        Section(
191
+            header: ContextInfoHeader(
192
+                title: "Charger",
193
+                message: "Chargers are edited separately from devices. Their charge-session metrics are learned automatically from wireless sessions."
194
+            )
195
+        ) {
196
+            EmptyView()
197
+        }
198
+    }
199
+
200
+    private var notesSection: some View {
201
+        Section(header: Text("Notes")) {
202
+            TextField("Optional notes", text: $notes)
203
+        }
204
+    }
205
+
207 206
     private var editorTitle: String {
208 207
         if chargedDevice == nil {
209
-            return deviceClass == .charger ? "New Charger" : "New Device"
208
+            return "New \(kind.title)"
210 209
         }
211
-        return chargedDevice?.isCharger == true ? "Edit Charger" : "Edit Device"
210
+        return "Edit \(kind.title)"
212 211
     }
213 212
 
214 213
     private var saveButtonTitle: String {
215 214
         chargedDevice == nil ? "Save" : "Update"
216 215
     }
217 216
 
217
+    private var canSave: Bool {
218
+        let hasValidName = name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
219
+        guard kind == .device else {
220
+            return hasValidName
221
+        }
222
+        return hasValidName
223
+            && (supportsWiredCharging || supportsWirelessCharging)
224
+            && !hasInvalidCompletionCurrentEntry
225
+    }
226
+
218 227
     private var supportedChargingModes: [ChargingTransportMode] {
219 228
         var modes: [ChargingTransportMode] = []
220 229
         if supportsWiredCharging {
@@ -254,20 +263,6 @@ struct ChargedDeviceEditorSheetView: View {
254 263
         }
255 264
     }
256 265
 
257
-    private var resolvedPreferredChargingTransportMode: ChargingTransportMode {
258
-        if supportedChargingModes.contains(preferredChargingTransportMode) {
259
-            return preferredChargingTransportMode
260
-        }
261
-        return supportsWiredCharging ? .wired : .wireless
262
-    }
263
-
264
-    private var preferredChargingTransportBinding: Binding<ChargingTransportMode> {
265
-        Binding(
266
-            get: { resolvedPreferredChargingTransportMode },
267
-            set: { preferredChargingTransportMode = $0 }
268
-        )
269
-    }
270
-
271 266
     private func completionCurrentTextBinding(for sessionKind: ChargeSessionKind) -> Binding<String> {
272 267
         Binding(
273 268
             get: { completionCurrentTexts[sessionKind] ?? "" },
@@ -275,6 +270,57 @@ struct ChargedDeviceEditorSheetView: View {
275 270
         )
276 271
     }
277 272
 
273
+    private func save() {
274
+        let didSave: Bool
275
+
276
+        if kind == .charger {
277
+            if let chargedDevice {
278
+                didSave = appData.updateCharger(
279
+                    id: chargedDevice.id,
280
+                    name: name,
281
+                    notes: notes
282
+                )
283
+            } else {
284
+                didSave = appData.createCharger(
285
+                    name: name,
286
+                    notes: notes,
287
+                    meterMACAddress: meterMACAddress
288
+                )
289
+            }
290
+        } else {
291
+            let configuredCompletionCurrents = parsedCompletionCurrents
292
+            if let chargedDevice {
293
+                didSave = appData.updateDevice(
294
+                    id: chargedDevice.id,
295
+                    name: name,
296
+                    deviceClass: deviceClass,
297
+                    chargingStateAvailability: chargingStateAvailability,
298
+                    supportsWiredCharging: supportsWiredCharging,
299
+                    supportsWirelessCharging: supportsWirelessCharging,
300
+                    wirelessChargingProfile: wirelessChargingProfile,
301
+                    configuredCompletionCurrents: configuredCompletionCurrents,
302
+                    notes: notes
303
+                )
304
+            } else {
305
+                didSave = appData.createDevice(
306
+                    name: name,
307
+                    deviceClass: deviceClass,
308
+                    chargingStateAvailability: chargingStateAvailability,
309
+                    supportsWiredCharging: supportsWiredCharging,
310
+                    supportsWirelessCharging: supportsWirelessCharging,
311
+                    wirelessChargingProfile: wirelessChargingProfile,
312
+                    configuredCompletionCurrents: configuredCompletionCurrents,
313
+                    notes: notes,
314
+                    meterMACAddress: meterMACAddress
315
+                )
316
+            }
317
+        }
318
+
319
+        if didSave {
320
+            dismiss()
321
+        }
322
+    }
323
+
278 324
     private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
279 325
         if chargedDevice != nil {
280 326
             return
@@ -286,23 +332,18 @@ struct ChargedDeviceEditorSheetView: View {
286 332
         case .iphone:
287 333
             supportsWiredCharging = true
288 334
             supportsWirelessCharging = true
289
-            preferredChargingTransportMode = .wired
290 335
         case .watch:
291 336
             supportsWiredCharging = false
292 337
             supportsWirelessCharging = true
293
-            preferredChargingTransportMode = .wireless
294 338
         case .powerbank:
295 339
             supportsWiredCharging = true
296 340
             supportsWirelessCharging = false
297
-            preferredChargingTransportMode = .wired
298 341
         case .charger:
299
-            supportsWiredCharging = true
342
+            supportsWiredCharging = false
300 343
             supportsWirelessCharging = true
301
-            preferredChargingTransportMode = .wireless
302 344
         case .other:
303 345
             supportsWiredCharging = true
304 346
             supportsWirelessCharging = false
305
-            preferredChargingTransportMode = .wired
306 347
         }
307 348
     }
308 349
 
@@ -346,9 +387,7 @@ struct ChargedDeviceEditorSheetView: View {
346 387
             return .onOnly
347 388
         case .powerbank:
348 389
             return .offOnly
349
-        case .charger:
350
-            return .onOnly
351
-        case .other:
390
+        case .charger, .other:
352 391
             return .onOnly
353 392
         }
354 393
     }
+54 -35
USB Meter/Views/ChargedDevices/ChargedDeviceLibrarySheetView.swift
@@ -11,30 +11,30 @@ enum ChargedDeviceLibraryMode {
11 11
     case device
12 12
     case charger
13 13
 
14
-    var title: String {
14
+    var kind: ChargedDeviceKind {
15 15
         switch self {
16 16
         case .device:
17
-            return "Devices"
17
+            return .device
18 18
         case .charger:
19
-            return "Chargers"
19
+            return .charger
20 20
         }
21 21
     }
22 22
 
23
-    var singularTitle: String {
23
+    var title: String {
24 24
         switch self {
25 25
         case .device:
26
-            return "Device"
26
+            return "Devices"
27 27
         case .charger:
28
-            return "Charger"
28
+            return "Chargers"
29 29
         }
30 30
     }
31 31
 
32
-    var suggestedClass: ChargedDeviceClass {
32
+    var singularTitle: String {
33 33
         switch self {
34 34
         case .device:
35
-            return .iphone
35
+            return "Device"
36 36
         case .charger:
37
-            return .charger
37
+            return "Charger"
38 38
         }
39 39
     }
40 40
 }
@@ -56,11 +56,14 @@ struct ChargedDeviceLibrarySheetView: View {
56 56
             List {
57 57
                 if displayedChargedDevices.isEmpty {
58 58
                     VStack(alignment: .leading, spacing: 10) {
59
-                        Text("No \(mode.title.lowercased()) yet.")
60
-                            .font(.headline)
61
-                        Text(emptyStateDescription)
62
-                            .font(.footnote)
63
-                            .foregroundColor(.secondary)
59
+                        HStack(spacing: 8) {
60
+                            Text("No \(mode.title.lowercased()) yet.")
61
+                                .font(.headline)
62
+                            ContextInfoButton(
63
+                                title: mode.title,
64
+                                message: emptyStateDescription
65
+                            )
66
+                        }
64 67
                     }
65 68
                     .padding(.vertical, 10)
66 69
                     .listRowBackground(Color.clear)
@@ -88,7 +91,7 @@ struct ChargedDeviceLibrarySheetView: View {
88 91
                             Button {
89 92
                                 editingChargedDevice = chargedDevice
90 93
                             } label: {
91
-                                Label("Edit Device", systemImage: "pencil")
94
+                                Label("Edit \(mode.singularTitle)", systemImage: "pencil")
92 95
                             }
93 96
                         }
94 97
                     }
@@ -122,15 +125,15 @@ struct ChargedDeviceLibrarySheetView: View {
122 125
         .sheet(isPresented: $editorVisibility) {
123 126
             ChargedDeviceEditorSheetView(
124 127
                 meterMACAddress: meterMACAddress,
125
-                suggestedDeviceClass: mode.suggestedClass
128
+                kind: mode.kind
126 129
             )
127 130
                 .environmentObject(appData)
128 131
         }
129 132
         .sheet(item: $editingChargedDevice) { chargedDevice in
130 133
             ChargedDeviceEditorSheetView(
131 134
                 meterMACAddress: nil,
132
-                chargedDevice: chargedDevice,
133
-                suggestedDeviceClass: mode.suggestedClass
135
+                kind: mode.kind,
136
+                chargedDevice: chargedDevice
134 137
             )
135 138
             .environmentObject(appData)
136 139
         }
@@ -183,7 +186,7 @@ private struct ChargedDeviceLibraryRowView: View {
183 186
 
184 187
             VStack(alignment: .leading, spacing: 6) {
185 188
                 HStack {
186
-                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
189
+                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
187 190
                         .font(.headline)
188 191
                         .foregroundColor(.primary)
189 192
                     Spacer()
@@ -193,28 +196,44 @@ private struct ChargedDeviceLibraryRowView: View {
193 196
                     }
194 197
                 }
195 198
 
196
-                Text(chargedDevice.deviceClass.title)
199
+                Text(chargedDevice.identityTitle)
197 200
                     .font(.caption.weight(.semibold))
198 201
                     .foregroundColor(.secondary)
199 202
 
200
-                Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
201
-                    .font(.caption2)
202
-                    .foregroundColor(.secondary)
203
-
204
-                if let capacity = chargedDevice.estimatedBatteryCapacityWh {
205
-                    Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
206
-                        .font(.caption)
207
-                        .foregroundColor(.secondary)
208
-                } else if chargedDevice.isCharger, let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
209
-                    Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
210
-                        .font(.caption)
203
+                if chargedDevice.isCharger {
204
+                    if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
205
+                        Text(
206
+                            chargedDevice.chargerObservedVoltageSelections
207
+                                .map { "\($0.format(decimalDigits: 1)) V" }
208
+                                .joined(separator: ", ")
209
+                        )
210
+                        .font(.caption2)
211 211
                         .foregroundColor(.secondary)
212
-                }
213
-
214
-                if let minimumCurrent = chargedDevice.resolvedCompletionCurrentAmps(for: chargedDevice.preferredChargingTransportMode) {
215
-                    Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
212
+                    } else if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
213
+                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
214
+                            .font(.caption2)
215
+                            .foregroundColor(.secondary)
216
+                    } else {
217
+                        Text("Wireless charger")
218
+                            .font(.caption2)
219
+                            .foregroundColor(.secondary)
220
+                    }
221
+                } else {
222
+                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
216 223
                         .font(.caption2)
217 224
                         .foregroundColor(.secondary)
225
+
226
+                    if let capacity = chargedDevice.estimatedBatteryCapacityWh {
227
+                        Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
228
+                            .font(.caption)
229
+                            .foregroundColor(.secondary)
230
+                    }
231
+
232
+                    if let minimumCurrent = chargedDevice.minimumCurrentAmps {
233
+                        Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
234
+                            .font(.caption2)
235
+                            .foregroundColor(.secondary)
236
+                    }
218 237
                 }
219 238
             }
220 239
         }
+23 -15
USB Meter/Views/ChargedDevices/SidebarChargedDevicesSectionView.swift
@@ -63,7 +63,7 @@ private struct ChargedDeviceSidebarCardView: View {
63 63
 
64 64
             VStack(alignment: .leading, spacing: 6) {
65 65
                 HStack {
66
-                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
66
+                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
67 67
                         .font(.headline)
68 68
                     if chargedDevice.activeSession != nil {
69 69
                         Spacer()
@@ -73,26 +73,34 @@ private struct ChargedDeviceSidebarCardView: View {
73 73
                     }
74 74
                 }
75 75
 
76
-                Text(chargedDevice.deviceClass.title)
76
+                Text(chargedDevice.identityTitle)
77 77
                     .font(.caption.weight(.semibold))
78 78
                     .foregroundColor(.secondary)
79 79
 
80
-                Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
81
-                    .font(.caption2)
82
-                    .foregroundColor(.secondary)
83
-
84
-                if let estimatedCapacityWh = chargedDevice.estimatedBatteryCapacityWh {
85
-                    Text("Capacity: \(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
86
-                        .font(.caption2)
87
-                        .foregroundColor(.secondary)
88
-                } else if chargedDevice.isCharger, let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
89
-                    Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
90
-                        .font(.caption2)
91
-                        .foregroundColor(.secondary)
80
+                if chargedDevice.isCharger {
81
+                    if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
82
+                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
83
+                            .font(.caption2)
84
+                            .foregroundColor(.secondary)
85
+                    } else {
86
+                        Text("Wireless charger")
87
+                            .font(.caption2)
88
+                            .foregroundColor(.secondary)
89
+                    }
92 90
                 } else {
93
-                    Text("Capacity: learning")
91
+                    Text(chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + "))
94 92
                         .font(.caption2)
95 93
                         .foregroundColor(.secondary)
94
+
95
+                    if let estimatedCapacityWh = chargedDevice.estimatedBatteryCapacityWh {
96
+                        Text("Capacity: \(estimatedCapacityWh.format(decimalDigits: 2)) Wh")
97
+                            .font(.caption2)
98
+                            .foregroundColor(.secondary)
99
+                    } else {
100
+                        Text("Capacity: learning")
101
+                            .font(.caption2)
102
+                            .foregroundColor(.secondary)
103
+                    }
96 104
                 }
97 105
             }
98 106
         }
+54 -0
USB Meter/Views/Components/Generic/ChevronView.swift
@@ -24,3 +24,57 @@ struct ChevronView: View {
24 24
         }
25 25
     }
26 26
 }
27
+
28
+struct ContextInfoButton: View {
29
+    let title: String
30
+    let message: String
31
+    let popoverWidth: CGFloat
32
+
33
+    @State private var showsPopover = false
34
+
35
+    init(
36
+        title: String,
37
+        message: String,
38
+        popoverWidth: CGFloat = 280
39
+    ) {
40
+        self.title = title
41
+        self.message = message
42
+        self.popoverWidth = popoverWidth
43
+    }
44
+
45
+    var body: some View {
46
+        Button {
47
+            showsPopover.toggle()
48
+        } label: {
49
+            Image(systemName: "info.circle")
50
+                .font(.body.weight(.semibold))
51
+                .foregroundColor(.secondary)
52
+        }
53
+        .buttonStyle(.plain)
54
+        .accessibilityLabel("\(title) info")
55
+        .popover(isPresented: $showsPopover, arrowEdge: .top) {
56
+            VStack(alignment: .leading, spacing: 10) {
57
+                Text(title)
58
+                    .font(.headline)
59
+                Text(message)
60
+                    .font(.body)
61
+                    .fixedSize(horizontal: false, vertical: true)
62
+            }
63
+            .padding(16)
64
+            .frame(width: popoverWidth, alignment: .leading)
65
+        }
66
+    }
67
+}
68
+
69
+struct ContextInfoHeader: View {
70
+    let title: String
71
+    let message: String
72
+
73
+    var body: some View {
74
+        HStack(spacing: 8) {
75
+            Text(title)
76
+            Spacer(minLength: 0)
77
+            ContextInfoButton(title: title, message: message)
78
+        }
79
+    }
80
+}
+50 -4
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -97,6 +97,8 @@ struct MeasurementChartView: View {
97 97
 
98 98
     let compactLayout: Bool
99 99
     let availableSize: CGSize
100
+    let showsRangeSelector: Bool
101
+    let rebasesEnergyToVisibleRangeStart: Bool
100 102
     
101 103
     @EnvironmentObject private var measurements: Measurements
102 104
     @Environment(\.horizontalSizeClass) private var horizontalSizeClass
@@ -128,11 +130,15 @@ struct MeasurementChartView: View {
128 130
     init(
129 131
         compactLayout: Bool = false,
130 132
         availableSize: CGSize = .zero,
131
-        timeRange: ClosedRange<Date>? = nil
133
+        timeRange: ClosedRange<Date>? = nil,
134
+        showsRangeSelector: Bool = true,
135
+        rebasesEnergyToVisibleRangeStart: Bool = false
132 136
     ) {
133 137
         self.compactLayout = compactLayout
134 138
         self.availableSize = availableSize
135 139
         self.timeRange = timeRange
140
+        self.showsRangeSelector = showsRangeSelector
141
+        self.rebasesEnergyToVisibleRangeStart = rebasesEnergyToVisibleRangeStart
136 142
     }
137 143
 
138 144
     private var axisColumnWidth: CGFloat {
@@ -153,6 +159,16 @@ struct MeasurementChartView: View {
153 159
         return isLargeDisplay ? 36 : 28
154 160
     }
155 161
 
162
+    private var belowXAxisControlsHeight: CGFloat {
163
+        if usesCompactLandscapeOriginControls {
164
+            return 40
165
+        }
166
+        if compactLayout {
167
+            return 46
168
+        }
169
+        return isLargeDisplay ? 58 : 50
170
+    }
171
+
156 172
     private var isPortraitLayout: Bool {
157 173
         guard availableSize != .zero else { return verticalSizeClass != .compact }
158 174
         return availableSize.height >= availableSize.width
@@ -280,7 +296,13 @@ struct MeasurementChartView: View {
280 296
                     chartToggleBar()
281 297
 
282 298
                     GeometryReader { geometry in
283
-                        let plotHeight = max(geometry.size.height - xAxisHeight, compactLayout ? 180 : 220)
299
+                        let reservedBottomHeight =
300
+                            xAxisHeight
301
+                            + (originControlsPlacement == .belowXAxisLegend ? belowXAxisControlsHeight + 6 : 0)
302
+                        let plotHeight = max(
303
+                            geometry.size.height - reservedBottomHeight,
304
+                            compactLayout ? 180 : 220
305
+                        )
284 306
 
285 307
                         VStack(spacing: 6) {
286 308
                             HStack(spacing: chartSectionSpacing) {
@@ -363,7 +385,8 @@ struct MeasurementChartView: View {
363 385
                                 }
364 386
                             }
365 387
 
366
-                            if let availableTimeRange,
388
+                            if showsRangeSelector,
389
+                               let availableTimeRange,
367 390
                                let selectorSeries,
368 391
                                shouldShowRangeSelector(
369 392
                                 availableTimeRange: availableTimeRange,
@@ -879,7 +902,8 @@ struct MeasurementChartView: View {
879 902
             measurement,
880 903
             visibleTimeRange: visibleTimeRange
881 904
         )
882
-        let points = smoothedPoints(from: rawPoints)
905
+        let normalizedRawPoints = normalizedPoints(rawPoints, for: kind)
906
+        let points = smoothedPoints(from: normalizedRawPoints)
883 907
         let samplePoints = points.filter { $0.isSample }
884 908
         let context = ChartContext()
885 909
 
@@ -921,6 +945,28 @@ struct MeasurementChartView: View {
921 945
         )
922 946
     }
923 947
 
948
+    private func normalizedPoints(
949
+        _ points: [Measurements.Measurement.Point],
950
+        for kind: SeriesKind
951
+    ) -> [Measurements.Measurement.Point] {
952
+        guard kind == .energy, rebasesEnergyToVisibleRangeStart else {
953
+            return points
954
+        }
955
+
956
+        guard let baseline = points.first(where: \.isSample)?.value else {
957
+            return points
958
+        }
959
+
960
+        return points.enumerated().map { index, point in
961
+            Measurements.Measurement.Point(
962
+                id: point.id == index ? point.id : index,
963
+                timestamp: point.timestamp,
964
+                value: point.value - baseline,
965
+                kind: point.kind
966
+            )
967
+        }
968
+    }
969
+
924 970
     private func overviewSeries(for kind: SeriesKind) -> SeriesData {
925 971
         series(
926 972
             for: measurement(for: kind),
+21 -3
USB Meter/Views/Meter/Components/MeterInfoCardView.swift
@@ -7,17 +7,35 @@ import SwiftUI
7 7
 
8 8
 struct MeterInfoCardView<Content: View>: View {
9 9
     let title: String
10
+    let infoMessage: String?
10 11
     let tint: Color
11 12
     @ViewBuilder var content: Content
12 13
 
14
+    init(
15
+        title: String,
16
+        infoMessage: String? = nil,
17
+        tint: Color,
18
+        @ViewBuilder content: () -> Content
19
+    ) {
20
+        self.title = title
21
+        self.infoMessage = infoMessage
22
+        self.tint = tint
23
+        self.content = content()
24
+    }
25
+
13 26
     var body: some View {
14 27
         VStack(alignment: .leading, spacing: 12) {
15
-            Text(title)
16
-                .font(.headline)
28
+            HStack(spacing: 8) {
29
+                Text(title)
30
+                    .font(.headline)
31
+                if let infoMessage {
32
+                    ContextInfoButton(title: title, message: infoMessage)
33
+                }
34
+            }
17 35
             content
18 36
         }
19 37
         .frame(maxWidth: .infinity, alignment: .leading)
20 38
         .padding(18)
21 39
         .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24)
22 40
     }
23
-}
41
+}
+6 -4
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -46,14 +46,16 @@ struct BatteryTargetNotificationEditorSheetView: View {
46 46
     var body: some View {
47 47
         NavigationView {
48 48
             Form {
49
-                Section(header: Text("Target Level")) {
49
+                Section(
50
+                    header: ContextInfoHeader(
51
+                        title: "Target Level",
52
+                        message: "A local notification will be generated on synced devices when the estimated battery level reaches this target."
53
+                    )
54
+                ) {
50 55
                     VStack(alignment: .leading, spacing: 12) {
51 56
                         Text("\(targetPercent.format(decimalDigits: 0))%")
52 57
                             .font(.title3.weight(.bold))
53 58
                         Slider(value: $targetPercent, in: 20...100, step: 1)
54
-                        Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
55
-                            .font(.footnote)
56
-                            .foregroundColor(.secondary)
57 59
                     }
58 60
                 }
59 61
             }
+11 -7
USB Meter/Views/Meter/Sheets/DataGroups/DataGroupsSheetView.swift
@@ -16,7 +16,7 @@ struct DataGroupsSheetView: View {
16 16
     var body: some View {
17 17
         NavigationView {
18 18
             GeometryReader { box in
19
-                let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"]
19
+                let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory"]
20 20
                     + (usbMeter.showsDataGroupEnergy ? ["Wh"] : [])
21 21
                     + (usbMeter.supportsDataGroupCommands ? ["Clear"] : [])
22 22
                 let columnWidth = (box.size.width - 60) / CGFloat(columnTitles.count)
@@ -24,12 +24,16 @@ struct DataGroupsSheetView: View {
24 24
                 ScrollView {
25 25
                     VStack(alignment: .leading, spacing: 14) {
26 26
                         VStack(alignment: .leading, spacing: 8) {
27
-                            Text(usbMeter.dataGroupsTitle)
28
-                                .font(.system(.title3, design: .rounded).weight(.bold))
29
-                            if let hint = usbMeter.dataGroupsHint {
30
-                                Text(hint)
31
-                                    .font(.footnote)
32
-                                    .foregroundColor(.secondary)
27
+                            HStack {
28
+                                Text(usbMeter.dataGroupsTitle)
29
+                                    .font(.system(.title3, design: .rounded).weight(.bold))
30
+                                if let hint = usbMeter.dataGroupsHint {
31
+                                    ContextInfoButton(
32
+                                        title: usbMeter.dataGroupsTitle,
33
+                                        message: hint
34
+                                    )
35
+                                }
36
+                                Spacer(minLength: 0)
33 37
                             }
34 38
                         }
35 39
                         .padding(18)
+1 -6
USB Meter/Views/Meter/Sheets/DataGroups/Subviews/DataGroupRowView.swift
@@ -34,12 +34,7 @@ struct DataGroupRowView: View {
34 34
                         .fontWeight(.semibold)
35 35
                 }
36 36
             }
37
-            
38
-            cell(width: width) {
39
-                Text("\(usbMeter.dataGroupRecords[Int(id)]!.ah.format(decimalDigits: 3))")
40
-                    .monospacedDigit()
41
-            }
42
-            
37
+
43 38
             if showsEnergy {
44 39
                 cell(width: width) {
45 40
                     Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
+9 -5
USB Meter/Views/Meter/Sheets/MeasurementSeries/MeasurementSeriesSheetView.swift
@@ -21,11 +21,15 @@ struct MeasurementSeriesSheetView: View {
21 21
             ScrollView {
22 22
                 VStack(alignment: .leading, spacing: 14) {
23 23
                     VStack(alignment: .leading, spacing: 8) {
24
-                        Text("Measurement Series")
25
-                            .font(.system(.title3, design: .rounded).weight(.bold))
26
-                        Text("Buffered measurement series captured from the meter for analysis, charts, and correlations.")
27
-                            .font(.footnote)
28
-                            .foregroundColor(.secondary)
24
+                        HStack {
25
+                            Text("Measurement Series")
26
+                                .font(.system(.title3, design: .rounded).weight(.bold))
27
+                            ContextInfoButton(
28
+                                title: "Measurement Series",
29
+                                message: "Buffered measurement series captured from the meter for analysis, charts, and correlations."
30
+                            )
31
+                            Spacer(minLength: 0)
32
+                        }
29 33
                     }
30 34
                     .padding(18)
31 35
                     .meterCard(tint: .blue, fillOpacity: 0.18, strokeOpacity: 0.24)
+420 -173
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -12,6 +12,25 @@ struct MeterChargeRecordTabView: View {
12 12
 }
13 13
 
14 14
 struct MeterChargeRecordContentView: View {
15
+    private enum InitialCheckpointMode: String, CaseIterable, Identifiable {
16
+        case known
17
+        case unknown
18
+        case flat
19
+
20
+        var id: String { rawValue }
21
+
22
+        var title: String {
23
+            switch self {
24
+            case .known:
25
+                return "Known"
26
+            case .unknown:
27
+                return "Unknown"
28
+            case .flat:
29
+                return "Flat"
30
+            }
31
+        }
32
+    }
33
+
15 34
     @EnvironmentObject private var appData: AppData
16 35
     @EnvironmentObject private var usbMeter: Meter
17 36
 
@@ -21,10 +40,60 @@ struct MeterChargeRecordContentView: View {
21 40
     @State private var editingChargedDevice: ChargedDeviceSummary?
22 41
     @State private var targetNotificationEditorVisibility = false
23 42
     @State private var pendingStopRequest: ChargeSessionStopRequest?
43
+    @State private var pendingCheckpointDeletion: ChargeCheckpointSummary?
24 44
     @State private var draftChargingTransportMode: ChargingTransportMode?
25 45
     @State private var draftChargingStateMode: ChargingStateMode?
26
-    @State private var draftAutoStopEnabled = true
46
+    @State private var initialCheckpointMode: InitialCheckpointMode = .known
27 47
     @State private var initialCheckpoint = ""
48
+    @State private var showsMeterTotalsInfo = false
49
+
50
+    private enum SessionStartRequirement: Identifiable {
51
+        case existingSession
52
+        case device
53
+        case chargingType
54
+        case chargingMode
55
+        case charger
56
+        case initialCheckpointEmpty
57
+        case initialCheckpointInvalid
58
+
59
+        var id: String {
60
+            switch self {
61
+            case .existingSession:
62
+                return "existing-session"
63
+            case .device:
64
+                return "device"
65
+            case .chargingType:
66
+                return "charging-type"
67
+            case .chargingMode:
68
+                return "charging-mode"
69
+            case .charger:
70
+                return "charger"
71
+            case .initialCheckpointEmpty:
72
+                return "initial-checkpoint-empty"
73
+            case .initialCheckpointInvalid:
74
+                return "initial-checkpoint-invalid"
75
+            }
76
+        }
77
+
78
+        var message: String {
79
+            switch self {
80
+            case .existingSession:
81
+                return "Stop or pause the current session before starting another one."
82
+            case .device:
83
+                return "Select the device that is charging."
84
+            case .chargingType:
85
+                return "Choose the charging type for this session."
86
+            case .chargingMode:
87
+                return "Choose whether the device is on or off for this session."
88
+            case .charger:
89
+                return "Select the wireless charger used in this session."
90
+            case .initialCheckpointEmpty:
91
+                return "Enter the initial battery percentage."
92
+            case .initialCheckpointInvalid:
93
+                return "Initial battery percentage must be between 0 and 100."
94
+            }
95
+        }
96
+    }
28 97
 
29 98
     var body: some View {
30 99
         ScrollView {
@@ -35,12 +104,14 @@ struct MeterChargeRecordContentView: View {
35 104
                 if let openChargeSession {
36 105
                     chargingMonitorCard(openChargeSession)
37 106
 
107
+                    if showsMeterTotalsCard {
108
+                        meterTotalsCard
109
+                    }
110
+
38 111
                     if let sessionChartTimeRange {
39 112
                         sessionChartCard(timeRange: sessionChartTimeRange, session: openChargeSession)
40 113
                     }
41
-                }
42
-
43
-                if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
114
+                } else if showsMeterTotalsCard {
44 115
                     meterTotalsCard
45 116
                 }
46 117
             }
@@ -80,6 +151,7 @@ struct MeterChargeRecordContentView: View {
80 151
         .sheet(item: $editingChargedDevice) { chargedDevice in
81 152
             ChargedDeviceEditorSheetView(
82 153
                 meterMACAddress: nil,
154
+                kind: chargedDevice.kind,
83 155
                 chargedDevice: chargedDevice
84 156
             )
85 157
             .environmentObject(appData)
@@ -102,6 +174,21 @@ struct MeterChargeRecordContentView: View {
102 174
             )
103 175
             .environmentObject(appData)
104 176
         }
177
+        .alert(item: $pendingCheckpointDeletion) { checkpoint in
178
+            Alert(
179
+                title: Text("Delete Battery Checkpoint"),
180
+                message: Text("Remove the checkpoint at \(checkpoint.timestamp.format()) with \(checkpoint.batteryPercent.format(decimalDigits: 0))% from this session?"),
181
+                primaryButton: .destructive(Text("Delete")) {
182
+                    if let openChargeSession {
183
+                        _ = appData.deleteBatteryCheckpoint(
184
+                            checkpointID: checkpoint.id,
185
+                            for: openChargeSession.id
186
+                        )
187
+                    }
188
+                },
189
+                secondaryButton: .cancel()
190
+            )
191
+        }
105 192
         .onAppear {
106 193
             syncDraftSelections()
107 194
         }
@@ -129,6 +216,14 @@ struct MeterChargeRecordContentView: View {
129 216
         appData.activeChargeSessionSummary(for: meterMACAddress)
130 217
     }
131 218
 
219
+    private var showsMeterTotalsCard: Bool {
220
+        usbMeter.supportsRecordingView
221
+            || usbMeter.supportsDataGroupCommands
222
+            || usbMeter.recordedAH > 0
223
+            || usbMeter.recordedWH > 0
224
+            || usbMeter.recordingDuration > 0
225
+    }
226
+
132 227
     private var selectedDraftTransportMode: ChargingTransportMode? {
133 228
         openChargeSession?.chargingTransportMode ?? draftChargingTransportMode
134 229
     }
@@ -137,31 +232,10 @@ struct MeterChargeRecordContentView: View {
137 232
         openChargeSession?.chargingStateMode ?? draftChargingStateMode
138 233
     }
139 234
 
140
-    private var selectedDraftSessionKind: ChargeSessionKind? {
141
-        guard let chargingTransportMode = selectedDraftTransportMode,
142
-              let chargingStateMode = selectedDraftChargingStateMode else {
143
-            return nil
144
-        }
145
-
146
-        return ChargeSessionKind(
147
-            chargingTransportMode: chargingTransportMode,
148
-            chargingStateMode: chargingStateMode
149
-        )
150
-    }
151
-
152
-    private var selectedDraftStopThreshold: Double? {
153
-        guard let selectedChargedDevice,
154
-              let chargingTransportMode = selectedDraftTransportMode else {
235
+    private var initialCheckpointValue: Double? {
236
+        guard initialCheckpointMode == .known else {
155 237
             return nil
156 238
         }
157
-
158
-        return selectedChargedDevice.resolvedCompletionCurrentAmps(
159
-            for: chargingTransportMode,
160
-            chargingStateMode: selectedDraftChargingStateMode
161
-        )
162
-    }
163
-
164
-    private var initialCheckpointValue: Double? {
165 239
         let normalized = initialCheckpoint
166 240
             .trimmingCharacters(in: .whitespacesAndNewlines)
167 241
             .replacingOccurrences(of: ",", with: ".")
@@ -171,6 +245,14 @@ struct MeterChargeRecordContentView: View {
171 245
         return value
172 246
     }
173 247
 
248
+    private var hasInitialCheckpointInput: Bool {
249
+        initialCheckpoint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
250
+    }
251
+
252
+    private var shouldRequireInitialCheckpoint: Bool {
253
+        initialCheckpointMode == .known
254
+    }
255
+
174 256
     private var requiresExplicitTransportSelection: Bool {
175 257
         (selectedChargedDevice?.supportedChargingModes.count ?? 0) > 1
176 258
     }
@@ -179,28 +261,53 @@ struct MeterChargeRecordContentView: View {
179 261
         (selectedChargedDevice?.supportedChargingStateModes.count ?? 0) > 1
180 262
     }
181 263
 
182
-    private var canStartSession: Bool {
183
-        guard openChargeSession == nil,
184
-              let selectedChargedDevice,
185
-              let chargingTransportMode = selectedDraftTransportMode,
186
-              let chargingStateMode = selectedDraftChargingStateMode,
187
-              let initialCheckpointValue else {
188
-            return false
264
+    private var startRequirements: [SessionStartRequirement] {
265
+        var requirements: [SessionStartRequirement] = []
266
+
267
+        if openChargeSession != nil {
268
+            requirements.append(.existingSession)
189 269
         }
190 270
 
191
-        guard selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) else {
192
-            return false
271
+        guard let selectedChargedDevice else {
272
+            requirements.append(.device)
273
+            return requirements
193 274
         }
194 275
 
195
-        guard selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) else {
196
-            return false
276
+        guard let chargingTransportMode = selectedDraftTransportMode else {
277
+            requirements.append(.chargingType)
278
+            return requirements
197 279
         }
198 280
 
199
-        if chargingTransportMode == .wireless {
200
-            return selectedCharger != nil
281
+        if selectedChargedDevice.supportedChargingModes.contains(chargingTransportMode) == false {
282
+            requirements.append(.chargingType)
201 283
         }
202 284
 
203
-        return true
285
+        guard let chargingStateMode = selectedDraftChargingStateMode else {
286
+            requirements.append(.chargingMode)
287
+            return requirements
288
+        }
289
+
290
+        if selectedChargedDevice.supportedChargingStateModes.contains(chargingStateMode) == false {
291
+            requirements.append(.chargingMode)
292
+        }
293
+
294
+        if chargingTransportMode == .wireless, selectedCharger == nil {
295
+            requirements.append(.charger)
296
+        }
297
+
298
+        if shouldRequireInitialCheckpoint {
299
+            if hasInitialCheckpointInput == false {
300
+                requirements.append(.initialCheckpointEmpty)
301
+            } else if initialCheckpointValue == nil {
302
+                requirements.append(.initialCheckpointInvalid)
303
+            }
304
+        }
305
+
306
+        return requirements
307
+    }
308
+
309
+    private var canStartSession: Bool {
310
+        startRequirements.isEmpty
204 311
     }
205 312
 
206 313
     private var headerStatusTitle: String {
@@ -236,51 +343,15 @@ struct MeterChargeRecordContentView: View {
236 343
         return openChargeSession.startedAt...max(end, openChargeSession.startedAt)
237 344
     }
238 345
 
239
-    private var draftAutoStopDescription: String {
240
-        guard let chargingTransportMode = selectedDraftTransportMode else {
241
-            return "Choose the charging type before starting the session."
242
-        }
243
-
244
-        if chargingTransportMode == .wireless, selectedCharger == nil {
245
-            return "Wireless sessions need a selected charger before they can start."
246
-        }
247
-
248
-        if draftAutoStopEnabled == false {
249
-            return "The session starts open-ended and will stop only when you pause or stop it manually."
250
-        }
251
-
252
-        if let setupWarning = setupWirelessThresholdWarning {
253
-            return setupWarning
254
-        }
255
-
256
-        if let selectedDraftSessionKind, let selectedDraftStopThreshold {
257
-            return "Auto-stop is ready for \(selectedDraftSessionKind.shortTitle.lowercased()) sessions at about \(selectedDraftStopThreshold.format(decimalDigits: 2)) A."
258
-        }
259
-
260
-        return "No stop threshold is known for this charging type yet, so the session starts open-ended."
261
-    }
262
-
263
-    private var setupWirelessThresholdWarning: String? {
264
-        guard selectedDraftTransportMode == .wireless else {
265
-            return nil
266
-        }
267
-
268
-        guard let selectedCharger else {
269
-            return nil
270
-        }
271
-
272
-        guard selectedCharger.chargerIdleCurrentAmps == nil else {
273
-            return nil
274
-        }
275
-
276
-        return "This charger has no idle-current measurement. Wireless sessions can still be recorded, but they cannot learn or auto-apply the final stop threshold yet."
277
-    }
278
-
279 346
     private var headerCard: some View {
280 347
         VStack(alignment: .leading, spacing: 8) {
281 348
             HStack {
282 349
                 Text("Charging Session")
283 350
                     .font(.system(.title3, design: .rounded).weight(.bold))
351
+                ContextInfoButton(
352
+                    title: "Charging Session",
353
+                    message: "Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit."
354
+                )
284 355
                 Spacer()
285 356
                 Text(headerStatusTitle)
286 357
                     .font(.caption.weight(.bold))
@@ -295,9 +366,6 @@ struct MeterChargeRecordContentView: View {
295 366
                     )
296 367
             }
297 368
 
298
-            Text("Recording starts only after the device, charging type, charging mode, and initial checkpoint are explicit.")
299
-                .font(.footnote)
300
-                .foregroundColor(.secondary)
301 369
         }
302 370
         .frame(maxWidth: .infinity)
303 371
         .padding(18)
@@ -309,6 +377,10 @@ struct MeterChargeRecordContentView: View {
309 377
             HStack {
310 378
                 Text(openChargeSession == nil ? "Session Setup" : "Session Context")
311 379
                     .font(.headline)
380
+                ContextInfoButton(
381
+                    title: openChargeSession == nil ? "Session Setup" : "Session Context",
382
+                    message: "Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection."
383
+                )
312 384
                 Spacer()
313 385
                 Button("Library") {
314 386
                     chargedDeviceLibraryVisibility = true
@@ -331,9 +403,7 @@ struct MeterChargeRecordContentView: View {
331 403
                 .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
332 404
                 .buttonStyle(.plain)
333 405
             } else {
334
-                Text("Select or create the device you are charging. Sessions, checkpoints, curve learning, and wireless charger warnings all depend on that explicit selection.")
335
-                    .font(.footnote)
336
-                    .foregroundColor(.secondary)
406
+                EmptyView()
337 407
             }
338 408
         }
339 409
         .padding(18)
@@ -349,10 +419,10 @@ struct MeterChargeRecordContentView: View {
349 419
                 )
350 420
 
351 421
                 VStack(alignment: .leading, spacing: 8) {
352
-                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
422
+                    Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
353 423
                         .font(.headline)
354 424
 
355
-                    Text(chargedDevice.deviceClass.title)
425
+                    Text(chargedDevice.identityTitle)
356 426
                         .font(.caption.weight(.semibold))
357 427
                         .foregroundColor(.secondary)
358 428
 
@@ -364,18 +434,8 @@ struct MeterChargeRecordContentView: View {
364 434
                         .font(.caption2)
365 435
                         .foregroundColor(.secondary)
366 436
 
367
-                    if let selectedDraftSessionKind,
368
-                       let threshold = chargedDevice.resolvedCompletionCurrentAmps(
369
-                        for: selectedDraftSessionKind.chargingTransportMode,
370
-                        chargingStateMode: selectedDraftSessionKind.chargingStateMode
371
-                       ) {
372
-                        Text("\(selectedDraftSessionKind.shortTitle) stop current: \(threshold.format(decimalDigits: 2)) A")
373
-                            .font(.caption2)
374
-                            .foregroundColor(.secondary)
375
-                    } else if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh(
376
-                        for: chargedDevice.preferredChargingTransportMode
377
-                    ) {
378
-                        Text("Estimated \(chargedDevice.preferredChargingTransportMode.title.lowercased()) capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
437
+                    if let estimatedCapacity = chargedDevice.estimatedBatteryCapacityWh {
438
+                        Text("Estimated capacity: \(estimatedCapacity.format(decimalDigits: 2)) Wh")
379 439
                             .font(.caption2)
380 440
                             .foregroundColor(.secondary)
381 441
                     }
@@ -398,13 +458,17 @@ struct MeterChargeRecordContentView: View {
398 458
                     Text("Charging Type")
399 459
                         .font(.subheadline.weight(.semibold))
400 460
 
401
-                    Picker("Charging Type", selection: $draftChargingTransportMode) {
402
-                        ForEach(chargedDevice.supportedChargingModes) { chargingTransportMode in
403
-                            Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
404
-                                .tag(Optional(chargingTransportMode))
461
+                    compactSelectionMenu(
462
+                        title: draftChargingTransportMode?.title ?? "Choose",
463
+                        options: chargedDevice.supportedChargingModes.map { chargingTransportMode in
464
+                            CompactSelectionOption(
465
+                                id: chargingTransportMode.id,
466
+                                title: chargingTransportMode.title,
467
+                                isSelected: draftChargingTransportMode == chargingTransportMode,
468
+                                action: { draftChargingTransportMode = chargingTransportMode }
469
+                            )
405 470
                         }
406
-                    }
407
-                    .pickerStyle(.segmented)
471
+                    )
408 472
 
409 473
                     if draftChargingTransportMode == nil {
410 474
                         Text("Pick the charging type explicitly before starting.")
@@ -425,13 +489,17 @@ struct MeterChargeRecordContentView: View {
425 489
                     Text("Charging Mode")
426 490
                         .font(.subheadline.weight(.semibold))
427 491
 
428
-                    Picker("Charging Mode", selection: $draftChargingStateMode) {
429
-                        ForEach(chargedDevice.supportedChargingStateModes) { chargingStateMode in
430
-                            Text(chargingStateMode.title)
431
-                                .tag(Optional(chargingStateMode))
492
+                    compactSelectionMenu(
493
+                        title: draftChargingStateMode?.title ?? "Choose",
494
+                        options: chargedDevice.supportedChargingStateModes.map { chargingStateMode in
495
+                            CompactSelectionOption(
496
+                                id: chargingStateMode.id,
497
+                                title: chargingStateMode.title,
498
+                                isSelected: draftChargingStateMode == chargingStateMode,
499
+                                action: { draftChargingStateMode = chargingStateMode }
500
+                            )
432 501
                         }
433
-                    }
434
-                    .pickerStyle(.segmented)
502
+                    )
435 503
 
436 504
                     if draftChargingStateMode == nil {
437 505
                         Text("Pick whether the device is on or off for this session.")
@@ -448,22 +516,84 @@ struct MeterChargeRecordContentView: View {
448 516
             }
449 517
 
450 518
             VStack(alignment: .leading, spacing: 8) {
451
-                Text("Initial Checkpoint")
452
-                    .font(.subheadline.weight(.semibold))
519
+                HStack(spacing: 8) {
520
+                    Text("Initial Checkpoint")
521
+                        .font(.subheadline.weight(.semibold))
522
+                    ContextInfoButton(
523
+                        title: "Initial Checkpoint",
524
+                        message: "Use the battery level shown by the device right now when it is known. A known checkpoint improves battery prediction and capacity learning, but the session can also start without one when the level is unavailable."
525
+                    )
526
+                }
453 527
 
454
-                TextField("Battery %", text: $initialCheckpoint)
455
-                    .keyboardType(.decimalPad)
528
+                compactSelectionMenu(
529
+                    title: initialCheckpointMode.title,
530
+                    options: InitialCheckpointMode.allCases.map { mode in
531
+                        CompactSelectionOption(
532
+                            id: mode.id,
533
+                            title: mode.title,
534
+                            isSelected: initialCheckpointMode == mode,
535
+                            action: { initialCheckpointMode = mode }
536
+                        )
537
+                    }
538
+                )
539
+
540
+                if initialCheckpointMode == .known {
541
+                    HStack(spacing: 10) {
542
+                        Button {
543
+                            adjustInitialCheckpoint(by: -1)
544
+                        } label: {
545
+                            Image(systemName: "minus.circle")
546
+                                .font(.title3)
547
+                        }
548
+                        .buttonStyle(.plain)
549
+
550
+                        TextField("Battery %", text: $initialCheckpoint)
551
+                            .keyboardType(.decimalPad)
552
+                            .textFieldStyle(.roundedBorder)
553
+                            .frame(width: 92)
554
+
555
+                        Text("%")
556
+                            .font(.subheadline.weight(.semibold))
557
+                            .foregroundColor(.secondary)
558
+
559
+                        Button {
560
+                            adjustInitialCheckpoint(by: 1)
561
+                        } label: {
562
+                            Image(systemName: "plus.circle")
563
+                                .font(.title3)
564
+                        }
565
+                        .buttonStyle(.plain)
566
+
567
+                        Spacer()
568
+                    }
569
+                } else {
570
+                    Text(
571
+                        initialCheckpointMode == .flat
572
+                        ? "Use Flat when the device does not turn on yet. Predictions and capacity estimates stay off until you record a positive battery level."
573
+                        : "Start without an initial battery checkpoint only when the level cannot be read reliably, for example on a device without display."
574
+                    )
575
+                        .font(.caption2)
576
+                        .foregroundColor(.orange)
577
+                }
456 578
 
457
-                Text("The session starts only after this first checkpoint is recorded.")
458
-                    .font(.caption2)
459
-                    .foregroundColor(.secondary)
460 579
             }
461 580
 
462
-            Toggle("Auto-stop when the type already has a stop threshold", isOn: $draftAutoStopEnabled)
581
+            VStack(alignment: .leading, spacing: 8) {
582
+                Text("Start Requirements")
583
+                    .font(.subheadline.weight(.semibold))
463 584
 
464
-            Text(draftAutoStopDescription)
465
-                .font(.footnote)
466
-                .foregroundColor(setupWirelessThresholdWarning == nil ? .secondary : .orange)
585
+                if startRequirements.isEmpty {
586
+                    Label("Everything needed to start is ready.", systemImage: "checkmark.circle.fill")
587
+                        .font(.caption)
588
+                        .foregroundColor(.green)
589
+                } else {
590
+                    ForEach(startRequirements) { requirement in
591
+                        Label(requirement.message, systemImage: "exclamationmark.circle")
592
+                            .font(.caption)
593
+                            .foregroundColor(.orange)
594
+                    }
595
+                }
596
+            }
467 597
 
468 598
             Button("Start Session") {
469 599
                 startSession()
@@ -496,7 +626,7 @@ struct MeterChargeRecordContentView: View {
496 626
                     )
497 627
 
498 628
                     VStack(alignment: .leading, spacing: 6) {
499
-                        Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
629
+                        Label(selectedCharger.name, systemImage: selectedCharger.identitySymbolName)
500 630
                             .font(.subheadline.weight(.semibold))
501 631
 
502 632
                         if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
@@ -525,21 +655,38 @@ struct MeterChargeRecordContentView: View {
525 655
     }
526 656
 
527 657
     private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
528
-        VStack(alignment: .leading, spacing: 12) {
529
-            Text("Charging Monitor")
530
-                .font(.headline)
658
+        let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
659
+
660
+        return VStack(alignment: .leading, spacing: 12) {
661
+            HStack(spacing: 8) {
662
+                Text("Charging Monitor")
663
+                    .font(.headline)
664
+                ContextInfoButton(
665
+                    title: "Charging Monitor",
666
+                    message: "The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own."
667
+                )
668
+            }
531 669
 
532 670
             ChargeRecordMetricsTableView(
533 671
                 labels: ["Type", "Mode", "Energy", "Auto Stop"],
534 672
                 values: [
535 673
                     openChargeSession.chargingTransportMode.title,
536 674
                     openChargeSession.chargingStateMode.title,
537
-                    "\(openChargeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh",
675
+                    "\(displayedEnergyWh.format(decimalDigits: 3)) Wh",
538 676
                     autoStopLabel(for: openChargeSession)
539 677
                 ]
540 678
             )
541 679
 
542
-            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(for: openChargeSession) {
680
+            if openChargeSession.stopThresholdAmps > 0 {
681
+                Text("Meter monitor threshold: \(openChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
682
+                    .font(.caption)
683
+                    .foregroundColor(.secondary)
684
+            }
685
+
686
+            if let batteryPrediction = selectedChargedDevice?.batteryLevelPrediction(
687
+                for: openChargeSession,
688
+                effectiveEnergyWhOverride: displayedEnergyWh
689
+            ) {
543 690
                 VStack(alignment: .leading, spacing: 4) {
544 691
                     Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
545 692
                         .font(.caption.weight(.semibold))
@@ -634,11 +781,12 @@ struct MeterChargeRecordContentView: View {
634 781
             .buttonStyle(.plain)
635 782
 
636 783
             if !openChargeSession.checkpoints.isEmpty {
784
+                let recentCheckpoints = Array(openChargeSession.checkpoints.suffix(6).reversed())
637 785
                 VStack(alignment: .leading, spacing: 8) {
638 786
                     Text("Battery Checkpoints")
639 787
                         .font(.subheadline.weight(.semibold))
640 788
 
641
-                    ForEach(openChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
789
+                    ForEach(recentCheckpoints, id: \.id) { checkpoint in
642 790
                         HStack {
643 791
                             Text(checkpoint.timestamp.format())
644 792
                                 .font(.caption2)
@@ -651,14 +799,18 @@ struct MeterChargeRecordContentView: View {
651 799
                             Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
652 800
                                 .font(.caption2)
653 801
                                 .foregroundColor(.secondary)
802
+                            Button {
803
+                                pendingCheckpointDeletion = checkpoint
804
+                            } label: {
805
+                                Image(systemName: "trash")
806
+                                    .font(.caption.weight(.semibold))
807
+                                    .foregroundColor(.red)
808
+                            }
809
+                            .buttonStyle(.plain)
654 810
                         }
655 811
                     }
656 812
                 }
657 813
             }
658
-
659
-            Text("The monitor keeps the session explicit: it never auto-selects another device or charger, and it never starts a new session on its own.")
660
-                .font(.footnote)
661
-                .foregroundColor(.secondary)
662 814
         }
663 815
         .padding(18)
664 816
         .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
@@ -709,16 +861,31 @@ struct MeterChargeRecordContentView: View {
709 861
         session: ChargeSessionSummary
710 862
     ) -> some View {
711 863
         VStack(alignment: .leading, spacing: 12) {
712
-            Text("Session Chart")
713
-                .font(.headline)
864
+            HStack(spacing: 8) {
865
+                Text("Session Chart")
866
+                    .font(.headline)
867
+                ContextInfoButton(
868
+                    title: "Session Chart",
869
+                    message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging."
870
+                )
871
+            }
714 872
 
715
-            MeasurementChartView(timeRange: timeRange)
873
+            GeometryReader { geometry in
874
+                let chartWidth = max(geometry.size.width, 1)
875
+                let compactChartLayout = chartWidth < 760
876
+                let chartHeight = compactChartLayout ? 290.0 : 350.0
877
+
878
+                MeasurementChartView(
879
+                    compactLayout: compactChartLayout,
880
+                    availableSize: CGSize(width: chartWidth, height: chartHeight),
881
+                    timeRange: timeRange,
882
+                    showsRangeSelector: false,
883
+                    rebasesEnergyToVisibleRangeStart: true
884
+                )
716 885
                 .environmentObject(usbMeter.measurements)
717
-                .frame(minHeight: 220)
718
-
719
-            Text("The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging.")
720
-                .font(.footnote)
721
-                .foregroundColor(.secondary)
886
+                .frame(maxWidth: .infinity, alignment: .topLeading)
887
+            }
888
+            .frame(height: 350)
722 889
         }
723 890
         .padding(18)
724 891
         .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20)
@@ -726,32 +893,42 @@ struct MeterChargeRecordContentView: View {
726 893
 
727 894
     private var meterTotalsCard: some View {
728 895
         VStack(alignment: .leading, spacing: 12) {
729
-            Text("Meter Totals")
730
-                .font(.headline)
896
+            HStack(spacing: 8) {
897
+                Text("Meter Recorder")
898
+                    .font(.headline)
899
+
900
+                Spacer(minLength: 0)
901
+
902
+                Button {
903
+                    showsMeterTotalsInfo.toggle()
904
+                } label: {
905
+                    Image(systemName: "info.circle")
906
+                        .font(.body.weight(.semibold))
907
+                        .foregroundColor(.secondary)
908
+                }
909
+                .buttonStyle(.plain)
910
+                .accessibilityLabel("Meter recorder info")
911
+                .popover(isPresented: $showsMeterTotalsInfo, arrowEdge: .top) {
912
+                    VStack(alignment: .leading, spacing: 10) {
913
+                        Text("Meter Recorder")
914
+                            .font(.headline)
915
+                        Text("These values come directly from the meter's built-in recorder. Keep them visible while comparing the app session against what the meter captured on its own.")
916
+                            .font(.body)
917
+                            .fixedSize(horizontal: false, vertical: true)
918
+                    }
919
+                    .padding(16)
920
+                    .frame(width: 280, alignment: .leading)
921
+                }
922
+            }
731 923
 
732 924
             ChargeRecordMetricsTableView(
733
-                labels: ["Capacity", "Energy", "Duration", "Meter Threshold"],
925
+                labels: ["Energy", "Duration", "Meter Threshold"],
734 926
                 values: [
735
-                    "\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah",
736 927
                     "\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh",
737 928
                     usbMeter.recordingDurationDescription,
738 929
                     usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
739 930
                 ]
740 931
             )
741
-
742
-            Text("These values come directly from the meter and remain separate from the explicit app session controls.")
743
-                .font(.footnote)
744
-                .foregroundColor(.secondary)
745
-
746
-            if usbMeter.supportsDataGroupCommands {
747
-                Button("Reset Active Group") {
748
-                    usbMeter.clear()
749
-                }
750
-                .frame(maxWidth: .infinity)
751
-                .padding(.vertical, 10)
752
-                .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
753
-                .buttonStyle(.plain)
754
-            }
755 932
         }
756 933
         .padding(18)
757 934
         .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
@@ -775,6 +952,23 @@ struct MeterChargeRecordContentView: View {
775 952
         return "Learning"
776 953
     }
777 954
 
955
+    private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
956
+        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
957
+        guard session.status.isOpen else {
958
+            return storedEnergyWh
959
+        }
960
+
961
+        guard session.meterMACAddress == meterMACAddress else {
962
+            return storedEnergyWh
963
+        }
964
+
965
+        if let baselineEnergyWh = session.meterEnergyBaselineWh {
966
+            return max(storedEnergyWh, max(usbMeter.recordedWH - baselineEnergyWh, 0))
967
+        }
968
+
969
+        return storedEnergyWh
970
+    }
971
+
778 972
     private func sessionWarning(for session: ChargeSessionSummary) -> String? {
779 973
         guard session.chargingTransportMode == .wireless,
780 974
               let chargerID = session.chargerID,
@@ -792,8 +986,7 @@ struct MeterChargeRecordContentView: View {
792 986
     private func startSession() {
793 987
         guard let selectedChargedDevice,
794 988
               let chargingTransportMode = selectedDraftTransportMode,
795
-              let chargingStateMode = selectedDraftChargingStateMode,
796
-              let initialCheckpointValue else {
989
+              let chargingStateMode = selectedDraftChargingStateMode else {
797 990
             return
798 991
         }
799 992
 
@@ -804,27 +997,37 @@ struct MeterChargeRecordContentView: View {
804 997
             chargerID: chargerID,
805 998
             chargingTransportMode: chargingTransportMode,
806 999
             chargingStateMode: chargingStateMode,
807
-            autoStopEnabled: draftAutoStopEnabled,
808
-            initialBatteryPercent: initialCheckpointValue
1000
+            autoStopEnabled: false,
1001
+            initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil,
1002
+            startsFromFlatBattery: initialCheckpointMode == .flat
809 1003
         )
810 1004
 
811 1005
         if didStart {
812 1006
             initialCheckpoint = ""
1007
+            initialCheckpointMode = .known
1008
+        }
1009
+    }
1010
+
1011
+    private func adjustInitialCheckpoint(by delta: Double) {
1012
+        guard initialCheckpointMode == .known else {
1013
+            return
813 1014
         }
1015
+
1016
+        let currentValue = initialCheckpointValue ?? 0
1017
+        let nextValue = min(max(currentValue + delta, 0), 100)
1018
+        initialCheckpoint = nextValue.format(decimalDigits: 0)
814 1019
     }
815 1020
 
816 1021
     private func syncDraftSelections() {
817 1022
         guard let selectedChargedDevice else {
818 1023
             draftChargingTransportMode = nil
819 1024
             draftChargingStateMode = nil
820
-            draftAutoStopEnabled = true
821 1025
             return
822 1026
         }
823 1027
 
824 1028
         if let openChargeSession {
825 1029
             draftChargingTransportMode = openChargeSession.chargingTransportMode
826 1030
             draftChargingStateMode = openChargeSession.chargingStateMode
827
-            draftAutoStopEnabled = openChargeSession.autoStopEnabled
828 1031
             return
829 1032
         }
830 1033
 
@@ -842,10 +1045,53 @@ struct MeterChargeRecordContentView: View {
842 1045
             draftChargingTransportMode = selectedChargedDevice.supportedChargingModes.first
843 1046
         }
844 1047
 
845
-        if selectedChargedDevice.supportedChargingStateModes.count == 1 {
1048
+        if let draftChargingTransportMode {
1049
+            draftChargingStateMode = draftChargingStateMode
1050
+                ?? selectedChargedDevice.defaultChargingStateMode(for: draftChargingTransportMode)
1051
+        } else if selectedChargedDevice.supportedChargingStateModes.count == 1 {
846 1052
             draftChargingStateMode = selectedChargedDevice.supportedChargingStateModes.first
847 1053
         }
848 1054
     }
1055
+
1056
+    private struct CompactSelectionOption: Identifiable {
1057
+        let id: String
1058
+        let title: String
1059
+        let isSelected: Bool
1060
+        let action: () -> Void
1061
+    }
1062
+
1063
+    private func compactSelectionMenu(
1064
+        title: String,
1065
+        options: [CompactSelectionOption]
1066
+    ) -> some View {
1067
+        Menu {
1068
+            ForEach(options) { option in
1069
+                Button {
1070
+                    option.action()
1071
+                } label: {
1072
+                    if option.isSelected {
1073
+                        Label(option.title, systemImage: "checkmark")
1074
+                    } else {
1075
+                        Text(option.title)
1076
+                    }
1077
+                }
1078
+            }
1079
+        } label: {
1080
+            HStack(spacing: 8) {
1081
+                Text(title)
1082
+                    .foregroundColor(.primary)
1083
+                Spacer()
1084
+                Image(systemName: "chevron.up.chevron.down")
1085
+                    .font(.caption.weight(.semibold))
1086
+                    .foregroundColor(.secondary)
1087
+            }
1088
+            .padding(.horizontal, 12)
1089
+            .padding(.vertical, 9)
1090
+            .frame(width: 180, alignment: .leading)
1091
+            .meterCard(tint: .secondary, fillOpacity: 0.08, strokeOpacity: 0.14, cornerRadius: 12)
1092
+        }
1093
+        .buttonStyle(.plain)
1094
+    }
849 1095
 }
850 1096
 
851 1097
 struct ChargeSessionCompletionSheetView: View {
@@ -863,17 +1109,18 @@ struct ChargeSessionCompletionSheetView: View {
863 1109
     var body: some View {
864 1110
         NavigationView {
865 1111
             Form {
866
-                Section(header: Text("Final Checkpoint")) {
1112
+                Section(
1113
+                    header: ContextInfoHeader(
1114
+                        title: "Final Checkpoint",
1115
+                        message: explanation
1116
+                    )
1117
+                ) {
867 1118
                     TextField("Battery %", text: $batteryPercent)
868 1119
                         .keyboardType(.decimalPad)
869 1120
                     TextField("Label", text: $label)
870 1121
                 }
871 1122
 
872 1123
                 Section {
873
-                    Text(explanation)
874
-                        .font(.footnote)
875
-                        .foregroundColor(.secondary)
876
-
877 1124
                     if let sessionWarning {
878 1125
                         Text(sessionWarning)
879 1126
                             .font(.footnote)
+10 -7
USB Meter/Views/Meter/Tabs/DataGroups/MeterDataGroupsTabView.swift
@@ -10,7 +10,7 @@ struct MeterDataGroupsTabView: View {
10 10
 
11 11
     var body: some View {
12 12
         GeometryReader { box in
13
-            let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"]
13
+            let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory"]
14 14
                 + (usbMeter.showsDataGroupEnergy ? ["Wh"] : [])
15 15
                 + (usbMeter.supportsDataGroupCommands ? ["Clear"] : [])
16 16
             let columnWidth = (box.size.width - 60) / CGFloat(columnTitles.count)
@@ -18,12 +18,15 @@ struct MeterDataGroupsTabView: View {
18 18
             ScrollView {
19 19
                 VStack(alignment: .leading, spacing: 14) {
20 20
                     VStack(alignment: .leading, spacing: 8) {
21
-                        Text(usbMeter.dataGroupsTitle)
22
-                            .font(.system(.title3, design: .rounded).weight(.bold))
23
-                        if let hint = usbMeter.dataGroupsHint {
24
-                            Text(hint)
25
-                                .font(.footnote)
26
-                                .foregroundColor(.secondary)
21
+                        HStack(spacing: 8) {
22
+                            Text(usbMeter.dataGroupsTitle)
23
+                                .font(.system(.title3, design: .rounded).weight(.bold))
24
+                            if let hint = usbMeter.dataGroupsHint {
25
+                                ContextInfoButton(
26
+                                    title: usbMeter.dataGroupsTitle,
27
+                                    message: hint
28
+                                )
29
+                            }
27 30
                         }
28 31
                     }
29 32
                     .padding(18)
+12 -6
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterOverviewSectionView.swift
@@ -12,9 +12,20 @@ import SwiftUI
12 12
 struct MeterOverviewSectionView: View {
13 13
     let meter: Meter
14 14
 
15
+    private var overviewInfoMessage: String? {
16
+        guard meter.operationalState != .dataIsAvailable else {
17
+            return nil
18
+        }
19
+        return "Connect to the meter to load firmware, serial, and boot details."
20
+    }
21
+
15 22
     var body: some View {
16 23
         VStack(spacing: 14) {
17
-            MeterInfoCardView(title: "Overview", tint: meter.color) {
24
+            MeterInfoCardView(
25
+                title: "Overview",
26
+                infoMessage: overviewInfoMessage,
27
+                tint: meter.color
28
+            ) {
18 29
                 MeterInfoRowView(label: "Name", value: meter.name)
19 30
                 MeterInfoRowView(label: "Device Model", value: meter.deviceModelName)
20 31
                 MeterInfoRowView(label: "Advertised Model", value: meter.modelString)
@@ -36,11 +47,6 @@ struct MeterOverviewSectionView: View {
36 47
                     if meter.bootCount != 0 {
37 48
                         MeterInfoRowView(label: "Boot Count", value: "\(meter.bootCount)")
38 49
                     }
39
-                } else {
40
-                    Text("Connect to the meter to load firmware, serial, and boot details.")
41
-                        .font(.footnote)
42
-                        .foregroundColor(.secondary)
43
-                        .multilineTextAlignment(.leading)
44 50
                 }
45 51
             }
46 52
 
+35 -22
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -394,11 +394,14 @@ private struct PowerAverageSheetView: View {
394 394
             ScrollView {
395 395
                 VStack(alignment: .leading, spacing: 14) {
396 396
                     VStack(alignment: .leading, spacing: 8) {
397
-                        Text("Power Average")
398
-                            .font(.system(.title3, design: .rounded).weight(.bold))
399
-                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
400
-                            .font(.footnote)
401
-                            .foregroundColor(.secondary)
397
+                        HStack(spacing: 8) {
398
+                            Text("Power Average")
399
+                                .font(.system(.title3, design: .rounded).weight(.bold))
400
+                            ContextInfoButton(
401
+                                title: "Power Average",
402
+                                message: "Inspect the recent power buffer, choose how many values to include, and compute the average power over that window."
403
+                            )
404
+                        }
402 405
                     }
403 406
                     .padding(18)
404 407
                     .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
@@ -435,11 +438,11 @@ private struct PowerAverageSheetView: View {
435 438
                         }
436 439
                     }
437 440
 
438
-                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
439
-                        Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
440
-                            .font(.footnote)
441
-                            .foregroundColor(.secondary)
442
-
441
+                    MeterInfoCardView(
442
+                        title: "Buffer Actions",
443
+                        infoMessage: "Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.",
444
+                        tint: .secondary
445
+                    ) {
443 446
                         Button("Reset Buffer") {
444 447
                             measurements.resetSeries()
445 448
                             selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
@@ -533,11 +536,14 @@ private struct RSSIHistorySheetView: View {
533 536
             ScrollView {
534 537
                 VStack(alignment: .leading, spacing: 14) {
535 538
                     VStack(alignment: .leading, spacing: 8) {
536
-                        Text("RSSI History")
537
-                            .font(.system(.title3, design: .rounded).weight(.bold))
538
-                        Text("Signal strength captured over time while the meter stays connected.")
539
-                            .font(.footnote)
540
-                            .foregroundColor(.secondary)
539
+                        HStack(spacing: 8) {
540
+                            Text("RSSI History")
541
+                                .font(.system(.title3, design: .rounded).weight(.bold))
542
+                            ContextInfoButton(
543
+                                title: "RSSI History",
544
+                                message: "Signal strength captured over time while the meter stays connected."
545
+                            )
546
+                        }
541 547
                     }
542 548
                     .padding(18)
543 549
                     .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
@@ -717,11 +723,14 @@ private struct EnergyProjectionSheetView: View {
717 723
             ScrollView {
718 724
                 VStack(alignment: .leading, spacing: 14) {
719 725
                     VStack(alignment: .leading, spacing: 8) {
720
-                        Text("Energy Projections")
721
-                            .font(.system(.title3, design: .rounded).weight(.bold))
722
-                        Text("Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data.")
723
-                            .font(.footnote)
724
-                            .foregroundColor(.secondary)
726
+                        HStack(spacing: 8) {
727
+                            Text("Energy Projections")
728
+                                .font(.system(.title3, design: .rounded).weight(.bold))
729
+                            ContextInfoButton(
730
+                                title: "Energy Projections",
731
+                                message: "Projected consumption is estimated from multiple real windows in the live buffer. A method is shown only when that full interval exists in the recent continuous data."
732
+                            )
733
+                        }
725 734
                     }
726 735
                     .padding(18)
727 736
                     .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
@@ -751,9 +760,13 @@ private struct EnergyProjectionSheetView: View {
751 760
                         }
752 761
                     }
753 762
 
754
-                    MeterInfoCardView(title: "Projection Method", tint: .teal) {
763
+                    MeterInfoCardView(
764
+                        title: "Projection Method",
765
+                        infoMessage: "Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.",
766
+                        tint: .teal
767
+                    ) {
755 768
                         if projectionVariants.isEmpty {
756
-                            Text("Projection methods appear after the live buffer contains at least one continuous interval with enough data to estimate a rate.")
769
+                            Text("No projection methods available yet.")
757 770
                                 .font(.footnote)
758 771
                                 .foregroundColor(.secondary)
759 772
                         } else {
+26 -25
USB Meter/Views/Meter/Tabs/Settings/MeterSettingsTabView.swift
@@ -39,10 +39,11 @@ struct MeterSettingsTabView: View {
39 39
                     }
40 40
 
41 41
                     if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
42
-                        settingsCard(title: "Meter Temperature Unit", tint: .orange) {
43
-                            Text("TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.")
44
-                                .font(.footnote)
45
-                                .foregroundColor(.secondary)
42
+                    settingsCard(
43
+                        title: "Meter Temperature Unit",
44
+                        infoMessage: "TC66 temperature is shown as degrees without assuming Celsius or Fahrenheit. Keep this matched to the unit configured on the device so you can interpret the reading correctly.",
45
+                        tint: .orange
46
+                    ) {
46 47
                             Picker("", selection: $meter.tc66TemperatureUnitPreference) {
47 48
                                 ForEach(TemperatureUnitPreference.allCases) { unit in
48 49
                                     Text(unit.title).tag(unit)
@@ -53,29 +54,23 @@ struct MeterSettingsTabView: View {
53 54
                     }
54 55
 
55 56
                     if meter.operationalState == .dataIsAvailable && meter.model == .TC66C {
56
-                        settingsCard(title: "Screen Reporting", tint: .orange) {
57
+                        settingsCard(
58
+                            title: "Screen Reporting",
59
+                            infoMessage: "TC66 is the exception: it does not report the current screen in the payload, so the app keeps this note here instead of showing it on the home screen.",
60
+                            tint: .orange
61
+                        ) {
57 62
                             MeterInfoRowView(label: "Current Screen", value: "Not Reported")
58
-                            Text("TC66 is the exception: it does not report the current screen in the payload, so the app keeps this note here instead of showing it on the home screen.")
59
-                                .font(.footnote)
60
-                                .foregroundColor(.secondary)
61 63
                         }
62 64
                     }
63 65
 
64 66
                     if meter.operationalState == .dataIsAvailable {
65 67
                         settingsCard(
66 68
                             title: meter.reportsCurrentScreenIndex ? "Screen Controls" : "Page Controls",
69
+                            infoMessage: meter.reportsCurrentScreenIndex
70
+                                ? "Use these controls when you want to change the screen shown on the device without crowding the main meter view."
71
+                                : "Use these controls when you want to switch device pages without crowding the main meter view.",
67 72
                             tint: .indigo
68 73
                         ) {
69
-                            if meter.reportsCurrentScreenIndex {
70
-                                Text("Use these controls when you want to change the screen shown on the device without crowding the main meter view.")
71
-                                    .font(.footnote)
72
-                                    .foregroundColor(.secondary)
73
-                            } else {
74
-                                Text("Use these controls when you want to switch device pages without crowding the main meter view.")
75
-                                    .font(.footnote)
76
-                                    .foregroundColor(.secondary)
77
-                            }
78
-
79 74
                             MeterScreenControlsView(showsHeader: false)
80 75
                         }
81 76
                     }
@@ -110,11 +105,11 @@ struct MeterSettingsTabView: View {
110 105
                         }
111 106
                     }
112 107
 
113
-                    settingsCard(title: "Danger Zone", tint: .red) {
114
-                        Text("Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.")
115
-                            .font(.footnote)
116
-                            .foregroundColor(.secondary)
117
-
108
+                    settingsCard(
109
+                        title: "Danger Zone",
110
+                        infoMessage: "Delete this meter from the sidebar and clear its saved metadata. If the device is still nearby, it can appear again after a fresh Bluetooth discovery.",
111
+                        tint: .red
112
+                    ) {
118 113
                         Button("Delete Meter") {
119 114
                             deleteConfirmationVisibility = true
120 115
                         }
@@ -186,12 +181,18 @@ struct MeterSettingsTabView: View {
186 181
 
187 182
     private func settingsCard<Content: View>(
188 183
         title: String,
184
+        infoMessage: String? = nil,
189 185
         tint: Color,
190 186
         @ViewBuilder content: () -> Content
191 187
     ) -> some View {
192 188
         VStack(alignment: .leading, spacing: 12) {
193
-            Text(title)
194
-                .font(.headline)
189
+            HStack(spacing: 8) {
190
+                Text(title)
191
+                    .font(.headline)
192
+                if let infoMessage {
193
+                    ContextInfoButton(title: title, message: infoMessage)
194
+                }
195
+            }
195 196
             content()
196 197
         }
197 198
         .padding(18)
+34 -20
USB Meter/Views/MeterDetailView.swift
@@ -76,8 +76,14 @@ struct MeterDetailView: View {
76 76
 
77 77
     private var statusCard: some View {
78 78
         VStack(alignment: .leading, spacing: 10) {
79
-            Text("Status")
80
-                .font(.headline)
79
+            HStack(spacing: 8) {
80
+                Text("Status")
81
+                    .font(.headline)
82
+                ContextInfoButton(
83
+                    title: "Status",
84
+                    message: "The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics."
85
+                )
86
+            }
81 87
             HStack(spacing: 8) {
82 88
                 Circle()
83 89
                     .fill(meterSummary.tint)
@@ -86,9 +92,6 @@ struct MeterDetailView: View {
86 92
                     .font(.caption.weight(.semibold))
87 93
                     .foregroundColor(.secondary)
88 94
             }
89
-            Text("The meter is not currently connected. Bring it within Bluetooth range or wake it up to open live diagnostics.")
90
-                .font(.caption)
91
-                .foregroundColor(.secondary)
92 95
         }
93 96
         .frame(maxWidth: .infinity, alignment: .leading)
94 97
         .padding(18)
@@ -113,11 +116,17 @@ struct MeterDetailView: View {
113 116
         let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress)
114 117
 
115 118
         return VStack(alignment: .leading, spacing: 10) {
116
-            Text("Devices")
117
-                .font(.headline)
119
+            HStack(spacing: 8) {
120
+                Text("Devices")
121
+                    .font(.headline)
122
+                ContextInfoButton(
123
+                    title: "Devices",
124
+                    message: "Link devices to this meter from Charge Record to keep capacity learning and charge curves tied to the right hardware."
125
+                )
126
+            }
118 127
 
119 128
             if chargedDevices.isEmpty {
120
-                Text("No devices are linked to this meter yet. Connect it, open Charge Record, and select the device being charged to start learning capacity and charge curves.")
129
+                Text("No devices linked yet.")
121 130
                     .font(.caption)
122 131
                     .foregroundColor(.secondary)
123 132
             } else {
@@ -127,7 +136,7 @@ struct MeterDetailView: View {
127 136
                             ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52)
128 137
 
129 138
                             VStack(alignment: .leading, spacing: 4) {
130
-                                Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
139
+                                Label(chargedDevice.name, systemImage: chargedDevice.identitySymbolName)
131 140
                                     .font(.subheadline.weight(.semibold))
132 141
                                 Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
133 142
                                     .font(.caption)
@@ -150,11 +159,17 @@ struct MeterDetailView: View {
150 159
         let chargers = appData.chargers(for: meterSummary.macAddress)
151 160
 
152 161
         return VStack(alignment: .leading, spacing: 10) {
153
-            Text("Chargers")
154
-                .font(.headline)
162
+            HStack(spacing: 8) {
163
+                Text("Chargers")
164
+                    .font(.headline)
165
+                ContextInfoButton(
166
+                    title: "Chargers",
167
+                    message: "Link chargers to this meter for wireless sessions so the app can keep charger-specific learning and efficiency data separate."
168
+                )
169
+            }
155 170
 
156 171
             if chargers.isEmpty {
157
-                Text("No chargers are linked to this meter yet. Pick one from Charge Record when you monitor a wireless charging session.")
172
+                Text("No chargers linked yet.")
158 173
                     .font(.caption)
159 174
                     .foregroundColor(.secondary)
160 175
             } else {
@@ -164,7 +179,7 @@ struct MeterDetailView: View {
164 179
                             ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52)
165 180
 
166 181
                             VStack(alignment: .leading, spacing: 4) {
167
-                                Label(charger.name, systemImage: charger.deviceClass.symbolName)
182
+                                Label(charger.name, systemImage: charger.identitySymbolName)
168 183
                                     .font(.subheadline.weight(.semibold))
169 184
                                 Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
170 185
                                     .font(.caption)
@@ -233,7 +248,12 @@ struct MeterEditorSheetView: View {
233 248
     var body: some View {
234 249
         NavigationView {
235 250
             Form {
236
-                Section(header: Text("Identity")) {
251
+                Section(
252
+                    header: ContextInfoHeader(
253
+                        title: "Identity",
254
+                        message: "Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline."
255
+                    )
256
+                ) {
237 257
                     TextField("Display name", text: $customName)
238 258
                     TextField("MAC Address", text: $macAddress)
239 259
                         .textInputAutocapitalization(.characters)
@@ -249,12 +269,6 @@ struct MeterEditorSheetView: View {
249 269
 
250 270
                     TextField("Advertised name", text: $advertisedName)
251 271
                 }
252
-
253
-                Section {
254
-                    Text("Use the real BLE MAC address format `AA:BB:CC:DD:EE:FF`. Saved meters remain visible in the sidebar even when they are currently offline.")
255
-                        .font(.footnote)
256
-                        .foregroundColor(.secondary)
257
-                }
258 272
             }
259 273
             .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
260 274
             .navigationBarTitleDisplayMode(.inline)
+4 -4
USB Meter/Views/MeterMappingDebugView.swift
@@ -17,16 +17,16 @@ struct MeterMappingDebugView: View {
17 17
                 VStack(alignment: .leading, spacing: 8) {
18 18
                     Text(store.currentCloudAvailability.helpTitle)
19 19
                         .font(.headline)
20
-                    Text("This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS.")
21
-                        .font(.caption)
22
-                        .foregroundColor(.secondary)
23 20
                     Text(store.currentCloudAvailability.helpMessage)
24 21
                         .font(.caption)
25 22
                         .foregroundColor(.secondary)
26 23
                 }
27 24
                 .padding(.vertical, 6)
28 25
             } header: {
29
-                Text("Sync Status")
26
+                ContextInfoHeader(
27
+                    title: "Sync Status",
28
+                    message: "This screen is limited to meter sync metadata visible on this device through the local store and iCloud KVS."
29
+                )
30 30
             }
31 31
 
32 32
             Section {
+7 -5
USB Meter/Views/Sidebar/SidebarList/Sections/Help/Components/SidebarBluetoothStatusCardView.swift
@@ -11,20 +11,22 @@ struct SidebarBluetoothStatusCardView: View {
11 11
 
12 12
     var body: some View {
13 13
         VStack(alignment: .leading, spacing: 6) {
14
-            HStack {
14
+            HStack(spacing: 8) {
15 15
                 Label("Bluetooth", systemImage: "bolt.horizontal.circle.fill")
16 16
                     .font(.footnote.weight(.semibold))
17 17
                     .foregroundColor(tint)
18
+                ContextInfoButton(
19
+                    title: "Bluetooth",
20
+                    message: "Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.",
21
+                    popoverWidth: 300
22
+                )
18 23
                 Spacer()
19 24
                 Text(statusText)
20 25
                     .font(.caption.weight(.semibold))
21 26
                     .foregroundColor(.secondary)
22 27
             }
23
-            Text("Refer to this adapter state while walking through the Bluetooth and Device troubleshooting steps.")
24
-                .font(.caption2)
25
-                .foregroundColor(.secondary)
26 28
         }
27 29
         .padding(14)
28 30
         .meterCard(tint: tint, fillOpacity: 0.22, strokeOpacity: 0.26, cornerRadius: 18)
29 31
     }
30
-}
32
+}
+2 -2
USB Meter/Views/Sidebar/SidebarView.swift
@@ -60,13 +60,13 @@ struct SidebarView: View {
60 60
             case .device:
61 61
                 ChargedDeviceEditorSheetView(
62 62
                     meterMACAddress: nil,
63
-                    suggestedDeviceClass: .iphone
63
+                    kind: .device
64 64
                 )
65 65
                 .environmentObject(appData)
66 66
             case .charger:
67 67
                 ChargedDeviceEditorSheetView(
68 68
                     meterMACAddress: nil,
69
-                    suggestedDeviceClass: .charger
69
+                    kind: .charger
70 70
                 )
71 71
                 .environmentObject(appData)
72 72
             }