Showing 31 changed files with 6735 additions and 29 deletions
+70 -0
USB Meter.xcodeproj/project.pbxproj
@@ -10,6 +10,15 @@
10 10
 		3407A133FADB8858DC2A1FED /* MeterNameStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */; };
11 11
 		4308CF8624176CAB0002E80B /* DataGroupRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF8524176CAB0002E80B /* DataGroupRowView.swift */; };
12 12
 		4308CF882417770D0002E80B /* DataGroupsSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4308CF872417770D0002E80B /* DataGroupsSheetView.swift */; };
13
+		C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */; };
14
+		C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */; };
15
+		C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */; };
16
+		C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */; };
17
+		C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */; };
18
+		C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */; };
19
+		C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */; };
20
+		C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */; };
21
+		C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */; };
13 22
 		430CB4FC245E07EB006525C2 /* ChevronView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 430CB4FB245E07EB006525C2 /* ChevronView.swift */; };
14 23
 		4311E63A241384960080EA59 /* DeviceHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4311E639241384960080EA59 /* DeviceHelpView.swift */; };
15 24
 		4327461B24619CED0009BE4B /* MeterRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4327461A24619CED0009BE4B /* MeterRowView.swift */; };
@@ -105,6 +114,20 @@
105 114
 		1C6B6BB42A2D4F5100A0B001 /* HM-10 and DX-BT18 Module Working Summary.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = "HM-10 and DX-BT18 Module Working Summary.md"; sourceTree = "<group>"; };
106 115
 		4308CF8524176CAB0002E80B /* DataGroupRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupRowView.swift; sourceTree = "<group>"; };
107 116
 		4308CF872417770D0002E80B /* DataGroupsSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataGroupsSheetView.swift; sourceTree = "<group>"; };
117
+		C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsModel.swift; sourceTree = "<group>"; };
118
+		C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsStore.swift; sourceTree = "<group>"; };
119
+		C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceQRCodeView.swift; sourceTree = "<group>"; };
120
+		C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceEditorSheetView.swift; sourceTree = "<group>"; };
121
+		C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceLibrarySheetView.swift; sourceTree = "<group>"; };
122
+		C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceDetailView.swift; sourceTree = "<group>"; };
123
+		C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarChargedDevicesSectionView.swift; sourceTree = "<group>"; };
124
+		C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryCheckpointEditorSheetView.swift; sourceTree = "<group>"; };
125
+		C10000193C8E4A7A00A10019 /* USB_Meter 4.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 4.xcdatamodel"; sourceTree = "<group>"; };
126
+		C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 5.xcdatamodel"; sourceTree = "<group>"; };
127
+		C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 6.xcdatamodel"; sourceTree = "<group>"; };
128
+		C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 7.xcdatamodel"; sourceTree = "<group>"; };
129
+		C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 8.xcdatamodel"; sourceTree = "<group>"; };
130
+		C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "USB_Meter 9.xcdatamodel"; sourceTree = "<group>"; };
108 131
 		430CB4FB245E07EB006525C2 /* ChevronView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChevronView.swift; sourceTree = "<group>"; };
109 132
 		4311E639241384960080EA59 /* DeviceHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceHelpView.swift; sourceTree = "<group>"; };
110 133
 		4327461A24619CED0009BE4B /* MeterRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeterRowView.swift; sourceTree = "<group>"; };
@@ -388,6 +411,9 @@
388 411
 			isa = PBXGroup;
389 412
 			children = (
390 413
 				4383B461240EB5E400DAAEBF /* AppData.swift */,
414
+				C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */,
415
+				C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */,
416
+				C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */,
391 417
 				7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */,
392 418
 				43CBF676240C043E00255B8B /* BluetoothManager.swift */,
393 419
 				4383B45F240EB2D000DAAEBF /* Meter.swift */,
@@ -407,6 +433,7 @@
407 433
 			children = (
408 434
 				43CBF666240BF3EB00255B8B /* ContentView.swift */,
409 435
 				AAD5F9BB2B1CC10000F8E4F9 /* Sidebar */,
436
+				C10000203C8E4A7A00A10020 /* ChargedDevices */,
410 437
 				56BA4CE53B6B2C4EBA42C81A /* MeterMappingDebugView.swift */,
411 438
 				4327461A24619CED0009BE4B /* MeterRowView.swift */,
412 439
 				437D47CF2415F8CF00B7768E /* Meter */,
@@ -417,6 +444,19 @@
417 444
 			path = Views;
418 445
 			sourceTree = "<group>";
419 446
 		};
447
+		C10000203C8E4A7A00A10020 /* ChargedDevices */ = {
448
+			isa = PBXGroup;
449
+			children = (
450
+				C10000163C8E4A7A00A10016 /* ChargedDeviceDetailView.swift */,
451
+				C10000143C8E4A7A00A10014 /* ChargedDeviceEditorSheetView.swift */,
452
+				C10000153C8E4A7A00A10015 /* ChargedDeviceLibrarySheetView.swift */,
453
+				C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */,
454
+				C10000183C8E4A7A00A10018 /* BatteryCheckpointEditorSheetView.swift */,
455
+				C10000173C8E4A7A00A10017 /* SidebarChargedDevicesSectionView.swift */,
456
+			);
457
+			path = ChargedDevices;
458
+			sourceTree = "<group>";
459
+		};
420 460
 		43CBF67F240D14AC00255B8B /* Extensions */ = {
421 461
 			isa = PBXGroup;
422 462
 			children = (
@@ -623,6 +663,9 @@
623 663
 					43CBF65B240BF3EB00255B8B = {
624 664
 						CreatedOnToolsVersion = 11.3.1;
625 665
 						SystemCapabilities = {
666
+							com.apple.BackgroundModes = {
667
+								enabled = 1;
668
+							};
626 669
 							com.apple.iCloud = {
627 670
 								enabled = 1;
628 671
 							};
@@ -692,6 +735,15 @@
692 735
 				D28F11393C8E4A7A00A10049 /* MeterConnectionStatusBadgeView.swift in Sources */,
693 736
 				D28F113B3C8E4A7A00A1004B /* MeterConnectionActionView.swift in Sources */,
694 737
 				D28F113D3C8E4A7A00A1004D /* MeterOverviewSectionView.swift in Sources */,
738
+				C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */,
739
+				C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */,
740
+				C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */,
741
+				C10000043C8E4A7A00A10004 /* ChargedDeviceEditorSheetView.swift in Sources */,
742
+				C10000053C8E4A7A00A10005 /* ChargedDeviceLibrarySheetView.swift in Sources */,
743
+				C10000063C8E4A7A00A10006 /* ChargedDeviceDetailView.swift in Sources */,
744
+				C10000073C8E4A7A00A10007 /* SidebarChargedDevicesSectionView.swift in Sources */,
745
+				C10000083C8E4A7A00A10008 /* BatteryCheckpointEditorSheetView.swift in Sources */,
746
+				C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */,
695 747
 				4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */,
696 748
 				437D47D12415F91B00B7768E /* MeterLiveContentView.swift in Sources */,
697 749
 				4383B465240EB6B200DAAEBF /* UserDefault.swift in Sources */,
@@ -743,6 +795,24 @@
743 795
 		};
744 796
 /* End PBXVariantGroup section */
745 797
 
798
+/* Begin XCVersionGroup section */
799
+		C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */ = {
800
+			isa = XCVersionGroup;
801
+			children = (
802
+				C10000193C8E4A7A00A10019 /* USB_Meter 4.xcdatamodel */,
803
+				C100001A3C8E4A7A00A1001A /* USB_Meter 5.xcdatamodel */,
804
+				C100001B3C8E4A7A00A1001B /* USB_Meter 6.xcdatamodel */,
805
+				C100001C3C8E4A7A00A1001C /* USB_Meter 7.xcdatamodel */,
806
+				C100001D3C8E4A7A00A1001D /* USB_Meter 8.xcdatamodel */,
807
+				C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */,
808
+			);
809
+			currentVersion = C100001E3C8E4A7A00A1001E /* USB_Meter 9.xcdatamodel */;
810
+			path = CKModel.xcdatamodeld;
811
+			sourceTree = "<group>";
812
+			versionGroupType = wrapper.xcdatamodel;
813
+		};
814
+/* End XCVersionGroup section */
815
+
746 816
 /* Begin XCBuildConfiguration section */
747 817
 		43CBF671240BF3ED00255B8B /* Debug */ = {
748 818
 			isa = XCBuildConfiguration;
+114 -1
USB Meter/AppDelegate.swift
@@ -6,7 +6,10 @@
6 6
 //  Copyright © 2020 Bogdan Timofte. All rights reserved.
7 7
 //
8 8
 
9
+import CloudKit
10
+import CoreData
9 11
 import UIKit
12
+import UserNotifications
10 13
 
11 14
 //let btSerial = BluetoothSerial(delegate: BSD())
12 15
 let appData = AppData()
@@ -118,11 +121,15 @@ private func shouldEmitTrackMessage(_ message: String, file: String, function: S
118 121
 }
119 122
 
120 123
 @UIApplicationMain
121
-class AppDelegate: UIResponder, UIApplicationDelegate {
124
+class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
125
+    private let cloudKitContainerIdentifier = "iCloud.ro.xdev.USB-Meter"
122 126
 
123 127
 
124 128
     func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
125 129
         logRuntimeICloudDiagnostics()
130
+        UNUserNotificationCenter.current().delegate = self
131
+        application.registerForRemoteNotifications()
132
+        appData.activateChargeInsights(context: persistentContainer.viewContext)
126 133
         return true
127 134
     }
128 135
 
@@ -130,6 +137,14 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
130 137
         #if DEBUG
131 138
         let hasUbiquityIdentityToken = FileManager.default.ubiquityIdentityToken != nil
132 139
         track("Runtime iCloud diagnostics: ubiquityIdentityTokenAvailable=\(hasUbiquityIdentityToken)")
140
+        CKContainer(identifier: cloudKitContainerIdentifier).accountStatus { status, error in
141
+            if let error {
142
+                track("CloudKit account status error: \(error.localizedDescription)")
143
+                return
144
+            }
145
+
146
+            track("CloudKit account status: \(status.rawValue)")
147
+        }
133 148
         #endif
134 149
     }
135 150
 
@@ -146,4 +161,102 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
146 161
         // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions.
147 162
         // Use this method to release any resources that were specific to the discarded scenes, as they will not return.
148 163
     }
164
+
165
+    func applicationWillTerminate(_ application: UIApplication) {
166
+        _ = appData.flushChargeInsights()
167
+        saveContext()
168
+    }
169
+
170
+    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
171
+        #if DEBUG
172
+        track("Registered for remote notifications with device token length \(deviceToken.count)")
173
+        #endif
174
+    }
175
+
176
+    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
177
+        track("Remote notification registration failed: \(error.localizedDescription)")
178
+    }
179
+
180
+    func application(
181
+        _ application: UIApplication,
182
+        didReceiveRemoteNotification userInfo: [AnyHashable : Any],
183
+        fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
184
+    ) {
185
+        #if DEBUG
186
+        track("Received remote notification with keys: \(userInfo.keys.map(String.init(describing:)).joined(separator: ", "))")
187
+        #endif
188
+        completionHandler(.newData)
189
+    }
190
+
191
+    lazy var persistentContainer: NSPersistentCloudKitContainer = {
192
+        let container = NSPersistentCloudKitContainer(name: "CKModel")
193
+
194
+        if let description = container.persistentStoreDescriptions.first {
195
+            description.cloudKitContainerOptions = NSPersistentCloudKitContainerOptions(
196
+                containerIdentifier: cloudKitContainerIdentifier
197
+            )
198
+            description.shouldMigrateStoreAutomatically = true
199
+            description.shouldInferMappingModelAutomatically = true
200
+            description.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey)
201
+            description.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)
202
+        }
203
+
204
+        container.loadPersistentStores { storeDescription, error in
205
+            if let error = error as NSError? {
206
+                NSLog("Core Data store load failed: %@", error.localizedDescription)
207
+
208
+                if let storeURL = storeDescription.url {
209
+                    let coordinator = container.persistentStoreCoordinator
210
+                    do {
211
+                        try coordinator.destroyPersistentStore(
212
+                            at: storeURL,
213
+                            ofType: storeDescription.type,
214
+                            options: nil
215
+                        )
216
+                        try coordinator.addPersistentStore(
217
+                            ofType: storeDescription.type,
218
+                            configurationName: nil,
219
+                            at: storeURL,
220
+                            options: storeDescription.options
221
+                        )
222
+                        NSLog("Recovered CloudKit store by recreating it at %@", storeURL.path)
223
+                        return
224
+                    } catch {
225
+                        NSLog("Core Data recovery attempt failed: %@", error.localizedDescription)
226
+                    }
227
+                }
228
+
229
+                #if DEBUG
230
+                fatalError("Unresolved Core Data error \(error), \(error.userInfo)")
231
+                #endif
232
+            }
233
+        }
234
+
235
+        container.viewContext.automaticallyMergesChangesFromParent = true
236
+        container.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
237
+        return container
238
+    }()
239
+
240
+    func saveContext() {
241
+        let context = persistentContainer.viewContext
242
+        guard context.hasChanges else { return }
243
+
244
+        do {
245
+            try context.save()
246
+        } catch {
247
+            let nsError = error as NSError
248
+            NSLog("Core Data save failed: %@", nsError.localizedDescription)
249
+            #if DEBUG
250
+            fatalError("Unresolved Core Data save error \(nsError), \(nsError.userInfo)")
251
+            #endif
252
+        }
253
+    }
254
+
255
+    func userNotificationCenter(
256
+        _ center: UNUserNotificationCenter,
257
+        willPresent notification: UNNotification,
258
+        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
259
+    ) {
260
+        completionHandler([.banner, .sound, .list])
261
+    }
149 262
 }
+4 -0
USB Meter/Info.plist
@@ -58,6 +58,10 @@
58 58
 			</array>
59 59
 		</dict>
60 60
 	</dict>
61
+	<key>UIBackgroundModes</key>
62
+	<array>
63
+		<string>remote-notification</string>
64
+	</array>
61 65
 	<key>UILaunchStoryboardName</key>
62 66
 	<string>LaunchScreen</string>
63 67
 	<key>UIRequiredDeviceCapabilities</key>
+558 -1
USB Meter/Model/AppData.swift
@@ -9,6 +9,8 @@
9 9
 import SwiftUI
10 10
 import Combine
11 11
 import CoreBluetooth
12
+import CoreData
13
+import UserNotifications
12 14
 
13 15
 final class AppData : ObservableObject {
14 16
     struct MeterSummary: Identifiable {
@@ -28,7 +30,11 @@ final class AppData : ObservableObject {
28 30
     private var bluetoothManagerNotification: AnyCancellable?
29 31
     private var meterStoreObserver: AnyCancellable?
30 32
     private var meterStoreCloudObserver: AnyCancellable?
33
+    private var chargeInsightsStoreObserver: AnyCancellable?
34
+    private var chargeInsightsRemoteObserver: AnyCancellable?
31 35
     private let meterStore = MeterNameStore.shared
36
+    private var chargeInsightsStore: ChargeInsightsStore?
37
+    private let chargeNotificationCoordinator = ChargeNotificationCoordinator()
32 38
 
33 39
     init() {
34 40
         bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
@@ -51,11 +57,51 @@ final class AppData : ObservableObject {
51 57
     @Published var enableRecordFeature: Bool = true
52 58
 
53 59
     @Published var meters: [UUID:Meter] = [UUID:Meter]()
60
+    @Published private(set) var chargedDevices: [ChargedDeviceSummary] = []
61
+
62
+    var deviceSummaries: [ChargedDeviceSummary] {
63
+        chargedDevices.filter { !$0.isCharger }
64
+    }
65
+
66
+    var chargerSummaries: [ChargedDeviceSummary] {
67
+        chargedDevices.filter { $0.isCharger }
68
+    }
54 69
 
55 70
     var cloudAvailability: MeterNameStore.CloudAvailability {
56 71
         meterStore.currentCloudAvailability
57 72
     }
58 73
 
74
+    func activateChargeInsights(context: NSManagedObjectContext) {
75
+        guard chargeInsightsStore == nil else {
76
+            return
77
+        }
78
+
79
+        context.automaticallyMergesChangesFromParent = true
80
+        context.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
81
+        chargeInsightsStore = ChargeInsightsStore(context: context)
82
+
83
+        chargeInsightsStoreObserver = NotificationCenter.default.publisher(
84
+            for: .NSManagedObjectContextObjectsDidChange,
85
+            object: context
86
+        )
87
+        .receive(on: DispatchQueue.main)
88
+        .sink { [weak self] _ in
89
+            self?.reloadChargedDevices()
90
+        }
91
+
92
+        chargeInsightsRemoteObserver = NotificationCenter.default.publisher(
93
+            for: .NSPersistentStoreRemoteChange,
94
+            object: nil
95
+        )
96
+        .receive(on: DispatchQueue.main)
97
+        .sink { [weak self] _ in
98
+            self?.reloadChargedDevices()
99
+        }
100
+
101
+        chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
102
+        reloadChargedDevices()
103
+    }
104
+
59 105
     func meterName(for macAddress: String) -> String? {
60 106
         meterStore.name(for: macAddress)
61 107
     }
@@ -93,6 +139,356 @@ final class AppData : ObservableObject {
93 139
         meterStore.lastConnected(for: macAddress)
94 140
     }
95 141
 
142
+    func chargedDeviceSummary(id: UUID) -> ChargedDeviceSummary? {
143
+        chargedDevices.first(where: { $0.id == id })
144
+    }
145
+
146
+    func chargedDevices(for meterMACAddress: String) -> [ChargedDeviceSummary] {
147
+        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
148
+        return chargedDevices.filter { chargedDevice in
149
+            guard chargedDevice.isCharger == false else {
150
+                return false
151
+            }
152
+            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
153
+                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
154
+        }
155
+    }
156
+
157
+    func chargers(for meterMACAddress: String) -> [ChargedDeviceSummary] {
158
+        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
159
+        return chargedDevices.filter { chargedDevice in
160
+            guard chargedDevice.isCharger else {
161
+                return false
162
+            }
163
+            return chargedDevice.lastAssociatedMeterMAC == normalizedMAC
164
+                || chargedDevice.sessions.contains(where: { $0.meterMACAddress == normalizedMAC })
165
+        }
166
+    }
167
+
168
+    func currentChargedDeviceSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
169
+        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
170
+
171
+        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
172
+           let liveDevice = chargedDevices.first(where: {
173
+               $0.id == activeSession.chargedDeviceID && $0.isCharger == false
174
+           }) {
175
+            return liveDevice
176
+        }
177
+
178
+        return chargedDevices.first(where: {
179
+            $0.isCharger == false && $0.lastAssociatedMeterMAC == normalizedMAC
180
+        })
181
+    }
182
+
183
+    func currentChargerSummary(for meterMACAddress: String) -> ChargedDeviceSummary? {
184
+        let normalizedMAC = meterMACAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
185
+
186
+        if let activeSession = activeChargeSessionSummary(for: normalizedMAC),
187
+           let chargerID = activeSession.chargerID,
188
+           let liveCharger = chargedDevices.first(where: {
189
+               $0.id == chargerID && $0.isCharger
190
+           }) {
191
+            return liveCharger
192
+        }
193
+
194
+        return chargedDevices.first(where: {
195
+            $0.isCharger && $0.lastAssociatedMeterMAC == normalizedMAC
196
+        })
197
+    }
198
+
199
+    func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
200
+        chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress)
201
+    }
202
+
203
+    @discardableResult
204
+    func createChargedDevice(
205
+        name: String,
206
+        deviceClass: ChargedDeviceClass,
207
+        supportsChargingWhileOff: Bool,
208
+        supportsWiredCharging: Bool,
209
+        supportsWirelessCharging: Bool,
210
+        preferredChargingTransportMode: ChargingTransportMode,
211
+        wirelessChargingProfile: WirelessChargingProfile,
212
+        wiredChargeCompletionCurrentAmps: Double?,
213
+        wirelessChargeCompletionCurrentAmps: Double?,
214
+        notes: String?,
215
+        meterMACAddress: String?
216
+    ) -> Bool {
217
+        let didSave = chargeInsightsStore?.createChargedDevice(
218
+            name: name,
219
+            deviceClass: deviceClass,
220
+            supportsChargingWhileOff: supportsChargingWhileOff,
221
+            supportsWiredCharging: supportsWiredCharging,
222
+            supportsWirelessCharging: supportsWirelessCharging,
223
+            preferredChargingTransportMode: preferredChargingTransportMode,
224
+            wirelessChargingProfile: wirelessChargingProfile,
225
+            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
226
+            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
227
+            notes: notes,
228
+            assignTo: meterMACAddress
229
+        ) ?? false
230
+
231
+        if didSave {
232
+            reloadChargedDevices()
233
+        }
234
+
235
+        return didSave
236
+    }
237
+
238
+    @discardableResult
239
+    func updateChargedDevice(
240
+        id: UUID,
241
+        name: String,
242
+        deviceClass: ChargedDeviceClass,
243
+        supportsChargingWhileOff: Bool,
244
+        supportsWiredCharging: Bool,
245
+        supportsWirelessCharging: Bool,
246
+        preferredChargingTransportMode: ChargingTransportMode,
247
+        wirelessChargingProfile: WirelessChargingProfile,
248
+        wiredChargeCompletionCurrentAmps: Double?,
249
+        wirelessChargeCompletionCurrentAmps: Double?,
250
+        notes: String?
251
+    ) -> Bool {
252
+        let didSave = chargeInsightsStore?.updateChargedDevice(
253
+            id: id,
254
+            name: name,
255
+            deviceClass: deviceClass,
256
+            supportsChargingWhileOff: supportsChargingWhileOff,
257
+            supportsWiredCharging: supportsWiredCharging,
258
+            supportsWirelessCharging: supportsWirelessCharging,
259
+            preferredChargingTransportMode: preferredChargingTransportMode,
260
+            wirelessChargingProfile: wirelessChargingProfile,
261
+            wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps,
262
+            wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps,
263
+            notes: notes
264
+        ) ?? false
265
+
266
+        if didSave {
267
+            reloadChargedDevices()
268
+        }
269
+
270
+        return didSave
271
+    }
272
+
273
+    @discardableResult
274
+    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meter: Meter) -> Bool {
275
+        let didSave = chargeInsightsStore?.setChargingTransportMode(
276
+            chargingTransportMode,
277
+            for: meter.btSerial.macAddress.description
278
+        ) ?? false
279
+
280
+        if didSave {
281
+            reloadChargedDevices()
282
+        }
283
+
284
+        return didSave
285
+    }
286
+
287
+    @discardableResult
288
+    func assignChargedDevice(_ chargedDeviceID: UUID, to meterMACAddress: String) -> Bool {
289
+        let didSave = chargeInsightsStore?.assignChargedDevice(id: chargedDeviceID, to: meterMACAddress) ?? false
290
+        if didSave {
291
+            reloadChargedDevices()
292
+        }
293
+        return didSave
294
+    }
295
+
296
+    @discardableResult
297
+    func assignCharger(_ chargerID: UUID, to meterMACAddress: String) -> Bool {
298
+        let didSave = chargeInsightsStore?.assignCharger(id: chargerID, to: meterMACAddress) ?? false
299
+        if didSave {
300
+            reloadChargedDevices()
301
+        }
302
+        return didSave
303
+    }
304
+
305
+    @discardableResult
306
+    func ensureChargeSession(for meter: Meter) -> Bool {
307
+        guard let snapshot = meter.chargingMonitorSnapshot else {
308
+            return false
309
+        }
310
+
311
+        let didSave = chargeInsightsStore?.ensureSession(for: snapshot, forceStart: true) ?? false
312
+        if didSave {
313
+            reloadChargedDevices()
314
+        }
315
+        return didSave
316
+    }
317
+
318
+    func observeChargeSnapshot(from meter: Meter, observedAt: Date = Date()) {
319
+        guard let snapshot = meter.chargingMonitorSnapshot(at: observedAt) else {
320
+            return
321
+        }
322
+
323
+        if chargeInsightsStore?.observe(snapshot: snapshot) == true {
324
+            reloadChargedDevices()
325
+        }
326
+    }
327
+
328
+    @discardableResult
329
+    func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
330
+        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
331
+            percent: percent,
332
+            label: label,
333
+            for: meter.btSerial.macAddress.description
334
+        ) ?? false
335
+
336
+        if didSave {
337
+            reloadChargedDevices()
338
+        }
339
+
340
+        return didSave
341
+    }
342
+
343
+    @discardableResult
344
+    func addBatteryCheckpoint(percent: Double, label: String?, for sessionID: UUID) -> Bool {
345
+        let didSave = chargeInsightsStore?.addBatteryCheckpoint(
346
+            percent: percent,
347
+            label: label,
348
+            for: sessionID
349
+        ) ?? false
350
+
351
+        if didSave {
352
+            reloadChargedDevices()
353
+        }
354
+
355
+        return didSave
356
+    }
357
+
358
+    @discardableResult
359
+    func flushChargeInsights() -> Bool {
360
+        let didSave = chargeInsightsStore?.flushPendingChanges() ?? false
361
+        reloadChargedDevices()
362
+        return didSave
363
+    }
364
+
365
+    @discardableResult
366
+    func setTargetBatteryPercent(_ percent: Double?, for meter: Meter) -> Bool {
367
+        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
368
+            return false
369
+        }
370
+        return setTargetBatteryPercent(percent, for: activeSession.id)
371
+    }
372
+
373
+    @discardableResult
374
+    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
375
+        if percent != nil {
376
+            chargeNotificationCoordinator.ensureAuthorizationIfNeeded()
377
+        }
378
+
379
+        let didSave = chargeInsightsStore?.setTargetBatteryPercent(percent, for: sessionID) ?? false
380
+        if didSave {
381
+            reloadChargedDevices()
382
+        }
383
+        return didSave
384
+    }
385
+
386
+    @discardableResult
387
+    func confirmChargeSessionCompletion(sessionID: UUID) -> Bool {
388
+        let didSave = chargeInsightsStore?.confirmCompletion(for: sessionID) ?? false
389
+        if didSave {
390
+            reloadChargedDevices()
391
+        }
392
+        return didSave
393
+    }
394
+
395
+    @discardableResult
396
+    func continueChargeSessionMonitoring(sessionID: UUID) -> Bool {
397
+        let didSave = chargeInsightsStore?.continueMonitoringDespiteCompletionContradiction(for: sessionID) ?? false
398
+        if didSave {
399
+            reloadChargedDevices()
400
+        }
401
+        return didSave
402
+    }
403
+
404
+    @discardableResult
405
+    func deleteChargeSession(sessionID: UUID) -> Bool {
406
+        let deletedSession = chargedDevices
407
+            .flatMap(\.sessions)
408
+            .first(where: { $0.id == sessionID })
409
+
410
+        let didDelete = chargeInsightsStore?.deleteChargeSession(id: sessionID) ?? false
411
+        guard didDelete else {
412
+            return false
413
+        }
414
+
415
+        if deletedSession?.status == .active,
416
+           let meterMACAddress = deletedSession?.meterMACAddress,
417
+           let liveMeter = meter(for: meterMACAddress) {
418
+            liveMeter.resetChargeRecord()
419
+        }
420
+
421
+        reloadChargedDevices()
422
+        return true
423
+    }
424
+
425
+    @discardableResult
426
+    func deleteChargedDevice(id: UUID) -> Bool {
427
+        let deletedDevice = chargedDeviceSummary(id: id)
428
+        let didDelete = chargeInsightsStore?.deleteChargedDevice(id: id) ?? false
429
+        guard didDelete else {
430
+            return false
431
+        }
432
+
433
+        if deletedDevice?.isCharger == false,
434
+           deletedDevice?.activeSession?.status == .active,
435
+           let meterMACAddress = deletedDevice?.activeSession?.meterMACAddress,
436
+           let liveMeter = meter(for: meterMACAddress) {
437
+            liveMeter.resetChargeRecord()
438
+        }
439
+
440
+        reloadChargedDevices()
441
+        return true
442
+    }
443
+
444
+    @discardableResult
445
+    func createKnownMeter(
446
+        macAddress: String,
447
+        customName: String?,
448
+        modelName: String,
449
+        advertisedName: String?
450
+    ) -> Bool {
451
+        let normalizedMAC = Self.normalizedMACAddress(macAddress)
452
+        guard Self.isValidMACAddress(normalizedMAC) else {
453
+            return false
454
+        }
455
+
456
+        registerMeter(macAddress: normalizedMAC, modelName: modelName, advertisedName: advertisedName)
457
+        if let customName = customName?.trimmingCharacters(in: .whitespacesAndNewlines), !customName.isEmpty {
458
+            setMeterName(customName, for: normalizedMAC)
459
+        }
460
+        noteMeterSeen(at: Date(), macAddress: normalizedMAC)
461
+        return true
462
+    }
463
+
464
+    @discardableResult
465
+    func deleteMeter(macAddress: String) -> Bool {
466
+        let normalizedMAC = Self.normalizedMACAddress(macAddress)
467
+        guard Self.isValidMACAddress(normalizedMAC) else {
468
+            return false
469
+        }
470
+
471
+        for meter in meters.values where meter.btSerial.macAddress.description == normalizedMAC {
472
+            meter.disconnect()
473
+        }
474
+        meters = meters.filter { element in
475
+            element.value.btSerial.macAddress.description != normalizedMAC
476
+        }
477
+
478
+        let didDelete = meterStore.remove(macAddress: normalizedMAC)
479
+        if didDelete {
480
+            scheduleObjectWillChange()
481
+        }
482
+        return didDelete
483
+    }
484
+
485
+    func restoreChargeMonitoringStateIfNeeded(for meter: Meter) {
486
+        guard let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description) else {
487
+            return
488
+        }
489
+        meter.restoreChargeMonitoringIfNeeded(from: activeSession)
490
+    }
491
+
96 492
     var meterSummaries: [MeterSummary] {
97 493
         let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
98 494
         let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
@@ -133,6 +529,20 @@ final class AppData : ObservableObject {
133 529
         }
134 530
     }
135 531
 
532
+    private func reloadChargedDevices() {
533
+        chargedDevices = chargeInsightsStore?.fetchChargedDeviceSummaries() ?? []
534
+        chargeNotificationCoordinator.process(chargedDevices: deviceSummaries)
535
+        for meter in meters.values {
536
+            restoreChargeMonitoringStateIfNeeded(for: meter)
537
+        }
538
+    }
539
+
540
+    private func meter(for meterMACAddress: String) -> Meter? {
541
+        meters.values.first { meter in
542
+            meter.btSerial.macAddress.description == meterMACAddress
543
+        }
544
+    }
545
+
136 546
     private func refreshMeterMetadata() {
137 547
         DispatchQueue.main.async { [weak self] in
138 548
             guard let self else { return }
@@ -174,7 +584,7 @@ extension AppData.MeterSummary {
174 584
     }
175 585
 }
176 586
 
177
-private extension AppData {
587
+extension AppData {
178 588
     static func friendlyDisplayName(liveMeter: Meter?, record: MeterNameStore.Record?) -> String {
179 589
         if let liveName = liveMeter?.name.trimmingCharacters(in: .whitespacesAndNewlines), !liveName.isEmpty {
180 590
             return liveName
@@ -193,4 +603,151 @@ private extension AppData {
193 603
         }
194 604
         return "Meter"
195 605
     }
606
+
607
+    static func normalizedMACAddress(_ macAddress: String) -> String {
608
+        macAddress
609
+            .trimmingCharacters(in: .whitespacesAndNewlines)
610
+            .uppercased()
611
+    }
612
+
613
+    static func isValidMACAddress(_ macAddress: String) -> Bool {
614
+        macAddress.range(
615
+            of: #"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$"#,
616
+            options: .regularExpression
617
+        ) != nil
618
+    }
619
+}
620
+
621
+private final class ChargeNotificationCoordinator {
622
+    private struct Payload {
623
+        let id: String
624
+        let title: String
625
+        let body: String
626
+        let threadIdentifier: String
627
+    }
628
+
629
+    private let notificationCenter = UNUserNotificationCenter.current()
630
+    private let deliveredIDsDefaultsKey = "ChargeNotificationCoordinator.deliveredIDs"
631
+    private let eventRecencyWindow: TimeInterval = 24 * 60 * 60
632
+    private var inFlightEventIDs: Set<String> = []
633
+
634
+    func ensureAuthorizationIfNeeded() {
635
+        notificationCenter.getNotificationSettings { [weak self] settings in
636
+            guard settings.authorizationStatus == .notDetermined else {
637
+                return
638
+            }
639
+
640
+            self?.notificationCenter.requestAuthorization(options: [.alert, .badge, .sound]) { _, error in
641
+                if let error {
642
+                    track("Notification authorization request failed: \(error.localizedDescription)")
643
+                }
644
+            }
645
+        }
646
+    }
647
+
648
+    func process(chargedDevices: [ChargedDeviceSummary]) {
649
+        let now = Date()
650
+        let pendingPayloads = chargedDevices.flatMap { chargedDevice in
651
+            payloads(for: chargedDevice, now: now)
652
+        }
653
+
654
+        for payload in pendingPayloads {
655
+            scheduleIfNeeded(payload)
656
+        }
657
+    }
658
+
659
+    private func payloads(for chargedDevice: ChargedDeviceSummary, now: Date) -> [Payload] {
660
+        chargedDevice.sessions.compactMap { session in
661
+            if let triggeredAt = session.targetBatteryAlertTriggeredAt,
662
+               now.timeIntervalSince(triggeredAt) <= eventRecencyWindow,
663
+               let targetBatteryPercent = session.targetBatteryPercent {
664
+                let estimatedPercent = chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
665
+                    ?? session.endBatteryPercent
666
+                    ?? targetBatteryPercent
667
+
668
+                return Payload(
669
+                    id: "target-\(session.id.uuidString)-\(Int(triggeredAt.timeIntervalSince1970))",
670
+                    title: "Battery target reached",
671
+                    body: "\(chargedDevice.name) reached about \(estimatedPercent.format(decimalDigits: 0))% on \(session.meterName ?? "USB Meter") (target \(targetBatteryPercent.format(decimalDigits: 0))%).",
672
+                    threadIdentifier: session.id.uuidString
673
+                )
674
+            }
675
+
676
+            if session.requiresCompletionConfirmation,
677
+               let requestedAt = session.completionConfirmationRequestedAt,
678
+               now.timeIntervalSince(requestedAt) <= eventRecencyWindow {
679
+                let estimatedPercent = session.completionContradictionPercent
680
+                    ?? chargedDevice.batteryLevelPrediction(for: session)?.predictedPercent
681
+                let bodyPrefix = "\(chargedDevice.name) may have stopped too early on \(session.meterName ?? "USB Meter")."
682
+                let detail = estimatedPercent.map {
683
+                    " Estimated battery is only \($0.format(decimalDigits: 0))%."
684
+                } ?? ""
685
+
686
+                return Payload(
687
+                    id: "completion-\(session.id.uuidString)-\(Int(requestedAt.timeIntervalSince1970))",
688
+                    title: "Confirm charge completion",
689
+                    body: bodyPrefix + detail,
690
+                    threadIdentifier: session.id.uuidString
691
+                )
692
+            }
693
+
694
+            return nil
695
+        }
696
+    }
697
+
698
+    private func scheduleIfNeeded(_ payload: Payload) {
699
+        guard deliveredEventIDs().contains(payload.id) == false else {
700
+            return
701
+        }
702
+
703
+        guard inFlightEventIDs.contains(payload.id) == false else {
704
+            return
705
+        }
706
+
707
+        inFlightEventIDs.insert(payload.id)
708
+
709
+        notificationCenter.getNotificationSettings { [weak self] settings in
710
+            guard let self else { return }
711
+            guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else {
712
+                DispatchQueue.main.async {
713
+                    self.inFlightEventIDs.remove(payload.id)
714
+                }
715
+                return
716
+            }
717
+
718
+            let content = UNMutableNotificationContent()
719
+            content.title = payload.title
720
+            content.body = payload.body
721
+            content.sound = .default
722
+            content.threadIdentifier = payload.threadIdentifier
723
+
724
+            let request = UNNotificationRequest(
725
+                identifier: payload.id,
726
+                content: content,
727
+                trigger: nil
728
+            )
729
+
730
+            self.notificationCenter.add(request) { error in
731
+                DispatchQueue.main.async {
732
+                    self.inFlightEventIDs.remove(payload.id)
733
+                    if let error {
734
+                        track("Failed scheduling local notification: \(error.localizedDescription)")
735
+                        return
736
+                    }
737
+                    self.storeDeliveredEventID(payload.id)
738
+                }
739
+            }
740
+        }
741
+    }
742
+
743
+    private func deliveredEventIDs() -> Set<String> {
744
+        let values = UserDefaults.standard.stringArray(forKey: deliveredIDsDefaultsKey) ?? []
745
+        return Set(values)
746
+    }
747
+
748
+    private func storeDeliveredEventID(_ id: String) {
749
+        var values = deliveredEventIDs()
750
+        values.insert(id)
751
+        UserDefaults.standard.set(Array(values), forKey: deliveredIDsDefaultsKey)
752
+    }
196 753
 }
+4 -1
USB Meter/Model/BluetoothManager.swift
@@ -69,8 +69,10 @@ class BluetoothManager : NSObject, ObservableObject {
69 69
             track("adding new USB Meter named '\(peripheralName)' with MAC Address: '\(macAddress)'")
70 70
             let btSerial = BluetoothSerial(peripheral: peripheral, radio: model.radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue)
71 71
             var m = appData.meters
72
-            m[peripheral.identifier] = Meter(model: model, with: btSerial)
72
+            let meter = Meter(model: model, with: btSerial)
73
+            m[peripheral.identifier] = meter
73 74
             appData.meters = m
75
+            appData.restoreChargeMonitoringStateIfNeeded(for: meter)
74 76
         } else if let meter = appData.meters[peripheral.identifier] {
75 77
             meter.lastSeen = Date()
76 78
             meter.btSerial.updateRSSI(RSSI.intValue)
@@ -78,6 +80,7 @@ class BluetoothManager : NSObject, ObservableObject {
78 80
             if meter.name == macAddress, let syncedName = appData.meterName(for: macAddress), syncedName != macAddress {
79 81
                 meter.updateNameFromStore(syncedName)
80 82
             }
83
+            appData.restoreChargeMonitoringStateIfNeeded(for: meter)
81 84
             if peripheral.delegate == nil {
82 85
                 peripheral.delegate = meter.btSerial
83 86
             }
+8 -0
USB Meter/Model/CKModel.xcdatamodeld/.xccurrentversion
@@ -0,0 +1,8 @@
1
+<?xml version="1.0" encoding="UTF-8"?>
2
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+<plist version="1.0">
4
+<dict>
5
+	<key>_XCCurrentVersionName</key>
6
+	<string>USB_Meter 9.xcdatamodel</string>
7
+</dict>
8
+</plist>
+67 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 4.xcdatamodel/contents
@@ -0,0 +1,67 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
9
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
10
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
11
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
12
+        <attribute name="notes" optional="YES" attributeType="String"/>
13
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
14
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
15
+    </entity>
16
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
17
+        <attribute name="id" optional="YES" attributeType="String"/>
18
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
19
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
20
+        <attribute name="meterName" optional="YES" attributeType="String"/>
21
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
22
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
23
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
24
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
25
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
26
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
27
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
28
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
29
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
30
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
31
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
32
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
33
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
34
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
35
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
36
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
37
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
38
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
39
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
40
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
41
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
42
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
44
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
45
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
46
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
47
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
48
+    </entity>
49
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
50
+        <attribute name="id" optional="YES" attributeType="String"/>
51
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
52
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
53
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
54
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
55
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
56
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
57
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
58
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
59
+        <attribute name="label" optional="YES" attributeType="String"/>
60
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
61
+    </entity>
62
+    <elements>
63
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="193"/>
64
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="478"/>
65
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
66
+    </elements>
67
+</model>
+75 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 5.xcdatamodel/contents
@@ -0,0 +1,75 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
9
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
11
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
12
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
13
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
14
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
15
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
18
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
19
+        <attribute name="notes" optional="YES" attributeType="String"/>
20
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
21
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
22
+    </entity>
23
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
24
+        <attribute name="id" optional="YES" attributeType="String"/>
25
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
26
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
27
+        <attribute name="meterName" optional="YES" attributeType="String"/>
28
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
29
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
30
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
31
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
33
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
34
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
35
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
36
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
37
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
38
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
39
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
40
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
41
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
42
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
43
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
44
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
45
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
49
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
50
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
51
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
52
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
53
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
54
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
55
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
56
+    </entity>
57
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
58
+        <attribute name="id" optional="YES" attributeType="String"/>
59
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
60
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
61
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
62
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
63
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
64
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
65
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
66
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
67
+        <attribute name="label" optional="YES" attributeType="String"/>
68
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
69
+    </entity>
70
+    <elements>
71
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="268"/>
72
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="493"/>
73
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
74
+    </elements>
75
+</model>
+91 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 6.xcdatamodel/contents
@@ -0,0 +1,91 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
9
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
11
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
12
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
13
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
14
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
15
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
18
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
19
+        <attribute name="notes" optional="YES" attributeType="String"/>
20
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
21
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
22
+    </entity>
23
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
24
+        <attribute name="id" optional="YES" attributeType="String"/>
25
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
26
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
27
+        <attribute name="meterName" optional="YES" attributeType="String"/>
28
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
29
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
30
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
31
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
33
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
34
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
35
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
36
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
37
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
38
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
39
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
40
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
41
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
42
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
43
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
44
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
45
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
49
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
50
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
51
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
52
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
53
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
54
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
55
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
56
+    </entity>
57
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
58
+        <attribute name="id" optional="YES" attributeType="String"/>
59
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
60
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
61
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
62
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
63
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
64
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
65
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
66
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
67
+        <attribute name="label" optional="YES" attributeType="String"/>
68
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
69
+    </entity>
70
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
71
+        <attribute name="id" optional="YES" attributeType="String"/>
72
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
73
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
74
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
75
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
76
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
77
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
79
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
80
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
81
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
82
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
83
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+    </entity>
85
+    <elements>
86
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="268"/>
87
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="493"/>
88
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
89
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
90
+    </elements>
91
+</model>
+97 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 7.xcdatamodel/contents
@@ -0,0 +1,97 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
9
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
11
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
12
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
13
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
14
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
15
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
18
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
19
+        <attribute name="notes" optional="YES" attributeType="String"/>
20
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
21
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
22
+    </entity>
23
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
24
+        <attribute name="id" optional="YES" attributeType="String"/>
25
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
26
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
27
+        <attribute name="meterName" optional="YES" attributeType="String"/>
28
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
29
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
30
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
31
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
33
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
34
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
35
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
36
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
37
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
38
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
39
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
40
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
41
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
42
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
43
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
44
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
45
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
49
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
50
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
51
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
52
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
53
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
54
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
56
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
57
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
58
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
60
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
61
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
62
+    </entity>
63
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
64
+        <attribute name="id" optional="YES" attributeType="String"/>
65
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
66
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
67
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
68
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
69
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
70
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
71
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
72
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
73
+        <attribute name="label" optional="YES" attributeType="String"/>
74
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
75
+    </entity>
76
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
77
+        <attribute name="id" optional="YES" attributeType="String"/>
78
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
79
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
80
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
81
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
82
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
83
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
84
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
85
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
86
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
87
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
88
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
89
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
90
+    </entity>
91
+    <elements>
92
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="268"/>
93
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="583"/>
94
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
95
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
96
+    </elements>
97
+</model>
+106 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 8.xcdatamodel/contents
@@ -0,0 +1,106 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
9
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
11
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
12
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
13
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
14
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
15
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
20
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
21
+        <attribute name="notes" optional="YES" attributeType="String"/>
22
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
23
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
24
+    </entity>
25
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
26
+        <attribute name="id" optional="YES" attributeType="String"/>
27
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
28
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
29
+        <attribute name="meterName" optional="YES" attributeType="String"/>
30
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
31
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
34
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
35
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
36
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
37
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
38
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
39
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
40
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
41
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
42
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
43
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
44
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
45
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
49
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
50
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
51
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
56
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
57
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
58
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
59
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
60
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
61
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
62
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
65
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
66
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
67
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
69
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
70
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
71
+    </entity>
72
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
73
+        <attribute name="id" optional="YES" attributeType="String"/>
74
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
75
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
76
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
77
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
79
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
80
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
81
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
82
+        <attribute name="label" optional="YES" attributeType="String"/>
83
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+    </entity>
85
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
86
+        <attribute name="id" optional="YES" attributeType="String"/>
87
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
88
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
89
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
90
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
91
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
92
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
93
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
94
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
95
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
96
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
97
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
98
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
99
+    </entity>
100
+    <elements>
101
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="298"/>
102
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="718"/>
103
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
104
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
105
+    </elements>
106
+</model>
+114 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 9.xcdatamodel/contents
@@ -0,0 +1,114 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
9
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
10
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
11
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
12
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
13
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
14
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
15
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
22
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
25
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
26
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
27
+        <attribute name="notes" optional="YES" attributeType="String"/>
28
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
29
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
30
+    </entity>
31
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
32
+        <attribute name="id" optional="YES" attributeType="String"/>
33
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
34
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
35
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
36
+        <attribute name="meterName" optional="YES" attributeType="String"/>
37
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
38
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
39
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
40
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
41
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
42
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
43
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
44
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
45
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
46
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
47
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
48
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
49
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
50
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
51
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
52
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
58
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
59
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
60
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
64
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
65
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
66
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
67
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
68
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
69
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
70
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
72
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
73
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
75
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
76
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
77
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
78
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
79
+    </entity>
80
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
81
+        <attribute name="id" optional="YES" attributeType="String"/>
82
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
83
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
84
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
85
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
86
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
87
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
88
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
89
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
90
+        <attribute name="label" optional="YES" attributeType="String"/>
91
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
92
+    </entity>
93
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
94
+        <attribute name="id" optional="YES" attributeType="String"/>
95
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
96
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
97
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
98
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
99
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
100
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
101
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
102
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
103
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
104
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
105
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
106
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
107
+    </entity>
108
+    <elements>
109
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="373"/>
110
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="748"/>
111
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
112
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
113
+    </elements>
114
+</model>
+412 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -0,0 +1,412 @@
1
+//
2
+//  ChargeInsightsModel.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import Foundation
9
+
10
+enum ChargedDeviceClass: String, CaseIterable, Identifiable {
11
+    case iphone
12
+    case watch
13
+    case powerbank
14
+    case charger
15
+    case other
16
+
17
+    var id: String { rawValue }
18
+
19
+    var title: String {
20
+        switch self {
21
+        case .iphone:
22
+            return "iPhone"
23
+        case .watch:
24
+            return "Watch"
25
+        case .powerbank:
26
+            return "Powerbank"
27
+        case .charger:
28
+            return "Charger"
29
+        case .other:
30
+            return "Other"
31
+        }
32
+    }
33
+
34
+    var symbolName: String {
35
+        switch self {
36
+        case .iphone:
37
+            return "iphone"
38
+        case .watch:
39
+            return "applewatch"
40
+        case .powerbank:
41
+            return "battery.100.bolt"
42
+        case .charger:
43
+            return "bolt.badge.clock"
44
+        case .other:
45
+            return "shippingbox"
46
+        }
47
+    }
48
+}
49
+
50
+enum ChargeSessionStatus: String {
51
+    case active
52
+    case completed
53
+    case abandoned
54
+
55
+    var title: String {
56
+        rawValue.capitalized
57
+    }
58
+}
59
+
60
+enum ChargeSessionSourceMode: String {
61
+    case live
62
+    case offline
63
+    case blended
64
+
65
+    var title: String {
66
+        switch self {
67
+        case .live:
68
+            return "Live"
69
+        case .offline:
70
+            return "Offline Counters"
71
+        case .blended:
72
+            return "Blended"
73
+        }
74
+    }
75
+}
76
+
77
+enum ChargingTransportMode: String, CaseIterable, Identifiable {
78
+    case wired
79
+    case wireless
80
+
81
+    var id: String { rawValue }
82
+
83
+    var title: String {
84
+        switch self {
85
+        case .wired:
86
+            return "Wired"
87
+        case .wireless:
88
+            return "Wireless"
89
+        }
90
+    }
91
+
92
+    var symbolName: String {
93
+        switch self {
94
+        case .wired:
95
+            return "cable.connector"
96
+        case .wireless:
97
+            return "dot.radiowaves.left.and.right"
98
+        }
99
+    }
100
+}
101
+
102
+enum WirelessChargingProfile: String, CaseIterable, Identifiable {
103
+    case magsafe
104
+    case genericQi
105
+
106
+    var id: String { rawValue }
107
+
108
+    var title: String {
109
+        switch self {
110
+        case .magsafe:
111
+            return "MagSafe"
112
+        case .genericQi:
113
+            return "Generic Qi"
114
+        }
115
+    }
116
+
117
+    var description: String {
118
+        switch self {
119
+        case .magsafe:
120
+            return "Use separate wireless-efficiency calibration from devices that also have reliable wired capacity."
121
+        case .genericQi:
122
+            return "Use only automatic efficiency estimates and show a low-efficiency warning when needed."
123
+        }
124
+    }
125
+}
126
+
127
+struct ChargeCheckpointSummary: Identifiable, Hashable {
128
+    let id: UUID
129
+    let sessionID: UUID
130
+    let chargedDeviceID: UUID
131
+    let timestamp: Date
132
+    let batteryPercent: Double
133
+    let measuredEnergyWh: Double
134
+    let measuredChargeAh: Double
135
+    let currentAmps: Double
136
+    let voltageVolts: Double?
137
+    let label: String?
138
+}
139
+
140
+struct ChargeSessionSampleSummary: Identifiable, Hashable {
141
+    let sessionID: UUID
142
+    let chargedDeviceID: UUID
143
+    let bucketIndex: Int
144
+    let timestamp: Date
145
+    let averageCurrentAmps: Double
146
+    let averageVoltageVolts: Double?
147
+    let averagePowerWatts: Double
148
+    let measuredEnergyWh: Double
149
+    let measuredChargeAh: Double
150
+    let sampleCount: Int
151
+
152
+    var id: String {
153
+        "\(sessionID.uuidString)-\(bucketIndex)"
154
+    }
155
+}
156
+
157
+struct ChargeSessionSummary: Identifiable, Hashable {
158
+    let id: UUID
159
+    let chargedDeviceID: UUID
160
+    let chargerID: UUID?
161
+    let meterMACAddress: String?
162
+    let meterName: String?
163
+    let meterModel: String?
164
+    let startedAt: Date
165
+    let endedAt: Date?
166
+    let lastObservedAt: Date
167
+    let status: ChargeSessionStatus
168
+    let sourceMode: ChargeSessionSourceMode
169
+    let chargingTransportMode: ChargingTransportMode
170
+    let measuredEnergyWh: Double
171
+    let effectiveBatteryEnergyWh: Double?
172
+    let measuredChargeAh: Double
173
+    let minimumObservedCurrentAmps: Double?
174
+    let maximumObservedCurrentAmps: Double?
175
+    let maximumObservedPowerWatts: Double?
176
+    let maximumObservedVoltageVolts: Double?
177
+    let selectedSourceVoltageVolts: Double?
178
+    let completionCurrentAmps: Double?
179
+    let stopThresholdAmps: Double
180
+    let startBatteryPercent: Double?
181
+    let endBatteryPercent: Double?
182
+    let capacityEstimateWh: Double?
183
+    let wirelessEfficiencyFactor: Double?
184
+    let usesEstimatedWirelessEfficiency: Bool
185
+    let shouldWarnAboutLowWirelessEfficiency: Bool
186
+    let supportsChargingWhileOff: Bool
187
+    let usedOfflineMeterCounters: Bool
188
+    let targetBatteryPercent: Double?
189
+    let targetBatteryAlertTriggeredAt: Date?
190
+    let requiresCompletionConfirmation: Bool
191
+    let completionConfirmationRequestedAt: Date?
192
+    let completionContradictionPercent: Double?
193
+    let selectedDataGroup: UInt8?
194
+    let checkpoints: [ChargeCheckpointSummary]
195
+    let aggregatedSamples: [ChargeSessionSampleSummary]
196
+
197
+    var duration: TimeInterval {
198
+        (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
199
+    }
200
+
201
+    var effectiveOrMeasuredEnergyWh: Double {
202
+        effectiveBatteryEnergyWh ?? measuredEnergyWh
203
+    }
204
+
205
+    var batteryDeltaPercent: Double? {
206
+        guard let startBatteryPercent, let endBatteryPercent else { return nil }
207
+        return endBatteryPercent - startBatteryPercent
208
+    }
209
+}
210
+
211
+struct BatteryLevelPrediction: Hashable {
212
+    let predictedPercent: Double
213
+    let estimatedCapacityWh: Double
214
+    let anchorPercent: Double
215
+    let anchorEnergyWh: Double
216
+    let anchorDescription: String
217
+}
218
+
219
+struct CapacityTrendPoint: Identifiable, Hashable {
220
+    let sessionID: UUID
221
+    let timestamp: Date
222
+    let capacityWh: Double
223
+    let chargingTransportMode: ChargingTransportMode
224
+
225
+    var id: UUID { sessionID }
226
+}
227
+
228
+struct TypicalChargeCurvePoint: Identifiable, Hashable {
229
+    let percentBin: Int
230
+    let averageEnergyWh: Double
231
+    let averageChargeAh: Double
232
+    let sampleCount: Int
233
+
234
+    var id: Int { percentBin }
235
+}
236
+
237
+struct ChargedDeviceSummary: Identifiable, Hashable {
238
+    let id: UUID
239
+    let qrIdentifier: String
240
+    let name: String
241
+    let deviceClass: ChargedDeviceClass
242
+    let supportsChargingWhileOff: Bool
243
+    let supportsWiredCharging: Bool
244
+    let supportsWirelessCharging: Bool
245
+    let preferredChargingTransportMode: ChargingTransportMode
246
+    let wirelessChargingProfile: WirelessChargingProfile
247
+    let wirelessChargerEfficiencyFactor: Double?
248
+    let wiredChargeCompletionCurrentAmps: Double?
249
+    let wirelessChargeCompletionCurrentAmps: Double?
250
+    let chargerObservedVoltageSelections: [Double]
251
+    let chargerIdleCurrentAmps: Double?
252
+    let chargerEfficiencyFactor: Double?
253
+    let chargerMaximumPowerWatts: Double?
254
+    let notes: String?
255
+    let minimumCurrentAmps: Double?
256
+    let estimatedBatteryCapacityWh: Double?
257
+    let wiredMinimumCurrentAmps: Double?
258
+    let wirelessMinimumCurrentAmps: Double?
259
+    let wiredEstimatedBatteryCapacityWh: Double?
260
+    let wirelessEstimatedBatteryCapacityWh: Double?
261
+    let lastAssociatedMeterMAC: String?
262
+    let createdAt: Date
263
+    let updatedAt: Date
264
+    let sessions: [ChargeSessionSummary]
265
+    let capacityHistory: [CapacityTrendPoint]
266
+    let typicalCurve: [TypicalChargeCurvePoint]
267
+
268
+    var isCharger: Bool {
269
+        deviceClass == .charger
270
+    }
271
+
272
+    var activeSession: ChargeSessionSummary? {
273
+        sessions.first(where: { $0.status == .active })
274
+    }
275
+
276
+    var recentCompletedSessions: [ChargeSessionSummary] {
277
+        sessions.filter { $0.status == .completed }
278
+    }
279
+
280
+    var sessionCount: Int {
281
+        sessions.count
282
+    }
283
+
284
+    var supportedChargingModes: [ChargingTransportMode] {
285
+        var modes: [ChargingTransportMode] = []
286
+        if supportsWiredCharging {
287
+            modes.append(.wired)
288
+        }
289
+        if supportsWirelessCharging {
290
+            modes.append(.wireless)
291
+        }
292
+        return modes.isEmpty ? [preferredChargingTransportMode] : modes
293
+    }
294
+
295
+    func estimatedBatteryCapacityWh(for chargingTransportMode: ChargingTransportMode) -> Double? {
296
+        switch chargingTransportMode {
297
+        case .wired:
298
+            return wiredEstimatedBatteryCapacityWh
299
+        case .wireless:
300
+            return wirelessEstimatedBatteryCapacityWh
301
+        }
302
+    }
303
+
304
+    func minimumCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
305
+        switch chargingTransportMode {
306
+        case .wired:
307
+            return wiredMinimumCurrentAmps
308
+        case .wireless:
309
+            return wirelessMinimumCurrentAmps
310
+        }
311
+    }
312
+
313
+    func configuredCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
314
+        switch chargingTransportMode {
315
+        case .wired:
316
+            return wiredChargeCompletionCurrentAmps
317
+        case .wireless:
318
+            return wirelessChargeCompletionCurrentAmps
319
+        }
320
+    }
321
+
322
+    func resolvedCompletionCurrentAmps(for chargingTransportMode: ChargingTransportMode) -> Double? {
323
+        configuredCompletionCurrentAmps(for: chargingTransportMode)
324
+            ?? minimumCurrentAmps(for: chargingTransportMode)
325
+            ?? minimumCurrentAmps
326
+    }
327
+
328
+    func batteryLevelPrediction(for session: ChargeSessionSummary) -> BatteryLevelPrediction? {
329
+        let estimatedCapacityWh = session.capacityEstimateWh
330
+            ?? estimatedBatteryCapacityWh(for: session.chargingTransportMode)
331
+            ?? estimatedBatteryCapacityWh
332
+
333
+        guard let estimatedCapacityWh, estimatedCapacityWh > 0 else {
334
+            return nil
335
+        }
336
+
337
+        let effectiveEnergyWh = session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh
338
+
339
+        struct Anchor {
340
+            let percent: Double
341
+            let energyWh: Double
342
+            let description: String
343
+        }
344
+
345
+        var anchors: [Anchor] = []
346
+
347
+        if let startBatteryPercent = session.startBatteryPercent {
348
+            anchors.append(
349
+                Anchor(
350
+                    percent: startBatteryPercent,
351
+                    energyWh: 0,
352
+                    description: "session start"
353
+                )
354
+            )
355
+        }
356
+
357
+        anchors.append(
358
+            contentsOf: session.checkpoints
359
+                .sorted { lhs, rhs in
360
+                    if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
361
+                        return lhs.measuredEnergyWh < rhs.measuredEnergyWh
362
+                    }
363
+                    return lhs.timestamp < rhs.timestamp
364
+                }
365
+                .map { checkpoint in
366
+                    let trimmedLabel = checkpoint.label?.trimmingCharacters(in: .whitespacesAndNewlines)
367
+                    return Anchor(
368
+                        percent: checkpoint.batteryPercent,
369
+                        energyWh: checkpoint.measuredEnergyWh,
370
+                        description: trimmedLabel.map { "checkpoint \($0)" } ?? "last checkpoint"
371
+                    )
372
+                }
373
+        )
374
+
375
+        guard !anchors.isEmpty else {
376
+            return nil
377
+        }
378
+
379
+        let eligibleAnchors = anchors.filter { $0.energyWh <= effectiveEnergyWh + 0.05 }
380
+        let anchor = eligibleAnchors.last ?? anchors.first!
381
+        let energyDeltaWh = max(effectiveEnergyWh - anchor.energyWh, 0)
382
+        let predictedPercent = min(
383
+            100,
384
+            max(
385
+                0,
386
+                anchor.percent + ((energyDeltaWh / estimatedCapacityWh) * 100)
387
+            )
388
+        )
389
+
390
+        return BatteryLevelPrediction(
391
+            predictedPercent: predictedPercent,
392
+            estimatedCapacityWh: estimatedCapacityWh,
393
+            anchorPercent: anchor.percent,
394
+            anchorEnergyWh: anchor.energyWh,
395
+            anchorDescription: anchor.description
396
+        )
397
+    }
398
+}
399
+
400
+struct ChargingMonitorSnapshot {
401
+    let meterMACAddress: String
402
+    let meterName: String
403
+    let meterModel: String
404
+    let observedAt: Date
405
+    let voltageVolts: Double
406
+    let currentAmps: Double
407
+    let powerWatts: Double
408
+    let selectedDataGroup: UInt8?
409
+    let meterChargeCounterAh: Double?
410
+    let meterEnergyCounterWh: Double?
411
+    let fallbackStopThresholdAmps: Double
412
+}
+2089 -0
USB Meter/Model/ChargeInsightsStore.swift
@@ -0,0 +1,2089 @@
1
+//
2
+//  ChargeInsightsStore.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import CoreData
9
+import Foundation
10
+
11
+final class ChargeInsightsStore {
12
+    private enum EntityName {
13
+        static let chargedDevice = "ChargedDevice"
14
+        static let chargeSession = "ChargeSession"
15
+        static let chargeCheckpoint = "ChargeCheckpoint"
16
+        static let chargeSessionSample = "ChargeSessionSample"
17
+    }
18
+
19
+    private enum MeterAssignmentKind {
20
+        case chargedDevice
21
+        case charger
22
+
23
+        var expectsChargerClass: Bool {
24
+            switch self {
25
+            case .chargedDevice:
26
+                return false
27
+            case .charger:
28
+                return true
29
+            }
30
+        }
31
+    }
32
+
33
+    private static let persistedSamplesPerHour = 300
34
+    private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
35
+
36
+    private let context: NSManagedObjectContext
37
+    private let stopDetectionHoldDuration: TimeInterval = 20
38
+    private let maximumLiveIntegrationGap: TimeInterval = 20
39
+    private let activeSessionSaveInterval: TimeInterval = 15
40
+    private let counterDecreaseTolerance = 0.002
41
+    private let completionConfirmationCooldown: TimeInterval = 15 * 60
42
+    private let defaultCompletionPercentThreshold = 95.0
43
+    private let completionContradictionTolerancePercent = 2.0
44
+    private let minimumWirelessEfficiencyFactor = 0.35
45
+    private let maximumWirelessEfficiencyFactor = 0.95
46
+    private let lowWirelessEfficiencyThreshold = 0.72
47
+
48
+    init(context: NSManagedObjectContext) {
49
+        self.context = context
50
+    }
51
+
52
+    func refreshContext() {
53
+        context.performAndWait {
54
+            context.processPendingChanges()
55
+        }
56
+    }
57
+
58
+    @discardableResult
59
+    func flushPendingChanges() -> Bool {
60
+        var didSave = false
61
+        context.performAndWait {
62
+            context.processPendingChanges()
63
+            didSave = saveContext()
64
+        }
65
+        return didSave
66
+    }
67
+
68
+    @discardableResult
69
+    func createChargedDevice(
70
+        name: String,
71
+        deviceClass: ChargedDeviceClass,
72
+        supportsChargingWhileOff: Bool,
73
+        supportsWiredCharging: Bool,
74
+        supportsWirelessCharging: Bool,
75
+        preferredChargingTransportMode: ChargingTransportMode,
76
+        wirelessChargingProfile: WirelessChargingProfile,
77
+        wiredChargeCompletionCurrentAmps: Double?,
78
+        wirelessChargeCompletionCurrentAmps: Double?,
79
+        notes: String?,
80
+        assignTo meterMACAddress: String?
81
+    ) -> Bool {
82
+        let normalizedName = normalizedText(name)
83
+        guard !normalizedName.isEmpty else { return false }
84
+        guard supportsWiredCharging || supportsWirelessCharging else { return false }
85
+
86
+        var didSave = false
87
+        context.performAndWait {
88
+            guard let entity = NSEntityDescription.entity(forEntityName: EntityName.chargedDevice, in: context) else {
89
+                return
90
+            }
91
+
92
+            let object = NSManagedObject(entity: entity, insertInto: context)
93
+            let now = Date()
94
+            object.setValue(UUID().uuidString, forKey: "id")
95
+            object.setValue(normalizedName, forKey: "name")
96
+            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
97
+            object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
98
+            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
99
+            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
100
+            object.setValue(
101
+                resolvedPreferredChargingTransportMode(
102
+                    preferredChargingTransportMode,
103
+                    supportsWiredCharging: supportsWiredCharging,
104
+                    supportsWirelessCharging: supportsWirelessCharging
105
+                ).rawValue,
106
+                forKey: "preferredChargingTransportRawValue"
107
+            )
108
+            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
109
+            object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
110
+            object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
111
+            object.setValue(normalizedOptionalText(notes), forKey: "notes")
112
+            object.setValue(generateQRIdentifier(), forKey: "qrIdentifier")
113
+            object.setValue(normalizedOptionalText(meterMACAddress), forKey: "lastAssociatedMeterMAC")
114
+            object.setValue(now, forKey: "createdAt")
115
+            object.setValue(now, forKey: "updatedAt")
116
+            didSave = saveContext()
117
+        }
118
+        return didSave
119
+    }
120
+
121
+    @discardableResult
122
+    func updateChargedDevice(
123
+        id: UUID,
124
+        name: String,
125
+        deviceClass: ChargedDeviceClass,
126
+        supportsChargingWhileOff: Bool,
127
+        supportsWiredCharging: Bool,
128
+        supportsWirelessCharging: Bool,
129
+        preferredChargingTransportMode: ChargingTransportMode,
130
+        wirelessChargingProfile: WirelessChargingProfile,
131
+        wiredChargeCompletionCurrentAmps: Double?,
132
+        wirelessChargeCompletionCurrentAmps: Double?,
133
+        notes: String?
134
+    ) -> Bool {
135
+        let normalizedName = normalizedText(name)
136
+        guard !normalizedName.isEmpty else { return false }
137
+        guard supportsWiredCharging || supportsWirelessCharging else { return false }
138
+
139
+        var didSave = false
140
+        context.performAndWait {
141
+            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
142
+                return
143
+            }
144
+
145
+            let previousSupportsChargingWhileOff = boolValue(object, key: "supportsChargingWhileOff")
146
+            let previousSupportsWiredCharging = self.supportsWiredCharging(for: object)
147
+            let previousSupportsWirelessCharging = self.supportsWirelessCharging(for: object)
148
+            let previousPreferredChargingTransportMode = self.preferredChargingTransportMode(for: object)
149
+            let resolvedPreferredTransportMode = resolvedPreferredChargingTransportMode(
150
+                preferredChargingTransportMode,
151
+                supportsWiredCharging: supportsWiredCharging,
152
+                supportsWirelessCharging: supportsWirelessCharging
153
+            )
154
+            let now = Date()
155
+
156
+            object.setValue(normalizedName, forKey: "name")
157
+            object.setValue(deviceClass.rawValue, forKey: "deviceClassRawValue")
158
+            object.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
159
+            object.setValue(supportsWiredCharging, forKey: "supportsWiredCharging")
160
+            object.setValue(supportsWirelessCharging, forKey: "supportsWirelessCharging")
161
+            object.setValue(resolvedPreferredTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
162
+            object.setValue(wirelessChargingProfile.rawValue, forKey: "wirelessChargingProfileRawValue")
163
+            object.setValue(wiredChargeCompletionCurrentAmps, forKey: "wiredChargeCompletionCurrentAmps")
164
+            object.setValue(wirelessChargeCompletionCurrentAmps, forKey: "wirelessChargeCompletionCurrentAmps")
165
+            object.setValue(normalizedOptionalText(notes), forKey: "notes")
166
+            object.setValue(now, forKey: "updatedAt")
167
+
168
+            let shouldRecalculateSessionCapacity = previousSupportsChargingWhileOff != supportsChargingWhileOff
169
+            let shouldRefreshActiveSessions = shouldRecalculateSessionCapacity
170
+                || previousSupportsWiredCharging != supportsWiredCharging
171
+                || previousSupportsWirelessCharging != supportsWirelessCharging
172
+                || previousPreferredChargingTransportMode != resolvedPreferredTransportMode
173
+
174
+            if shouldRecalculateSessionCapacity || shouldRefreshActiveSessions {
175
+                let sessions = fetchSessions(forChargedDeviceID: id.uuidString)
176
+                for session in sessions {
177
+                    let isActive = statusValue(session, key: "statusRawValue") == .active
178
+
179
+                    if shouldRecalculateSessionCapacity {
180
+                        session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
181
+                        updateCapacityEstimate(for: session)
182
+                        session.setValue(now, forKey: "updatedAt")
183
+                    }
184
+
185
+                    guard isActive, shouldRefreshActiveSessions else {
186
+                        continue
187
+                    }
188
+
189
+                    let resolvedSessionChargingTransportMode = resolvedPreferredChargingTransportMode(
190
+                        chargingTransportMode(for: session),
191
+                        supportsWiredCharging: supportsWiredCharging,
192
+                        supportsWirelessCharging: supportsWirelessCharging
193
+                    )
194
+                    let fallbackStopThreshold = max(optionalDoubleValue(session, key: "stopThresholdAmps") ?? 0.01, 0.01)
195
+
196
+                    session.setValue(supportsChargingWhileOff, forKey: "supportsChargingWhileOff")
197
+                    session.setValue(resolvedSessionChargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
198
+                    session.setValue(
199
+                        resolvedStopThreshold(
200
+                            for: object,
201
+                            chargingTransportMode: resolvedSessionChargingTransportMode,
202
+                            fallback: fallbackStopThreshold
203
+                        ),
204
+                        forKey: "stopThresholdAmps"
205
+                    )
206
+                    session.setValue(now, forKey: "updatedAt")
207
+                    updateCapacityEstimate(for: session)
208
+                }
209
+            }
210
+
211
+            refreshDerivedMetrics(forChargedDeviceID: id.uuidString)
212
+            didSave = saveContext()
213
+        }
214
+        return didSave
215
+    }
216
+
217
+    @discardableResult
218
+    func assignChargedDevice(id: UUID, to meterMACAddress: String) -> Bool {
219
+        assign(itemWithID: id, to: meterMACAddress, kind: .chargedDevice)
220
+    }
221
+
222
+    @discardableResult
223
+    func assignCharger(id: UUID, to meterMACAddress: String) -> Bool {
224
+        assign(itemWithID: id, to: meterMACAddress, kind: .charger)
225
+    }
226
+
227
+    @discardableResult
228
+    private func assign(
229
+        itemWithID id: UUID,
230
+        to meterMACAddress: String,
231
+        kind: MeterAssignmentKind
232
+    ) -> Bool {
233
+        let normalizedMAC = normalizedMACAddress(meterMACAddress)
234
+        guard !normalizedMAC.isEmpty else { return false }
235
+
236
+        var didSave = false
237
+        context.performAndWait {
238
+            guard let object = fetchChargedDeviceObject(id: id.uuidString) else {
239
+                return
240
+            }
241
+
242
+            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
243
+            guard isCharger == kind.expectsChargerClass else {
244
+                return
245
+            }
246
+
247
+            let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
248
+            request.predicate = NSPredicate(
249
+                format: "lastAssociatedMeterMAC == %@ AND id != %@",
250
+                normalizedMAC,
251
+                id.uuidString
252
+            )
253
+            let previouslyAssignedDevices = (try? context.fetch(request)) ?? []
254
+            for previousDevice in previouslyAssignedDevices {
255
+                let previousIsCharger = ChargedDeviceClass(rawValue: stringValue(previousDevice, key: "deviceClassRawValue") ?? "") == .charger
256
+                guard previousIsCharger == kind.expectsChargerClass else {
257
+                    continue
258
+                }
259
+                previousDevice.setValue(nil, forKey: "lastAssociatedMeterMAC")
260
+                previousDevice.setValue(Date(), forKey: "updatedAt")
261
+            }
262
+
263
+            object.setValue(normalizedMAC, forKey: "lastAssociatedMeterMAC")
264
+            object.setValue(Date(), forKey: "updatedAt")
265
+
266
+            if kind == .charger,
267
+               let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC),
268
+               chargingTransportMode(for: activeSession) == .wireless {
269
+                activeSession.setValue(id.uuidString, forKey: "chargerID")
270
+                activeSession.setValue(Date(), forKey: "updatedAt")
271
+            }
272
+
273
+            didSave = saveContext()
274
+        }
275
+        return didSave
276
+    }
277
+
278
+    @discardableResult
279
+    func setChargingTransportMode(_ chargingTransportMode: ChargingTransportMode, for meterMACAddress: String) -> Bool {
280
+        let normalizedMAC = normalizedMACAddress(meterMACAddress)
281
+        guard !normalizedMAC.isEmpty else { return false }
282
+
283
+        var didSave = false
284
+        context.performAndWait {
285
+            let activeSession = fetchActiveSessionObject(forMeterMACAddress: normalizedMAC)
286
+            let device = (activeSession.flatMap { stringValue($0, key: "chargedDeviceID") }.flatMap(fetchChargedDeviceObject(id:)))
287
+                ?? resolvedDeviceObject(for: normalizedMAC)
288
+
289
+            guard let device else {
290
+                return
291
+            }
292
+
293
+            let resolvedMode = resolvedPreferredChargingTransportMode(
294
+                chargingTransportMode,
295
+                supportsWiredCharging: supportsWiredCharging(for: device),
296
+                supportsWirelessCharging: supportsWirelessCharging(for: device)
297
+            )
298
+            let charger = resolvedMode == .wireless ? resolvedChargerObject(for: normalizedMAC) : nil
299
+            guard resolvedMode == .wired || charger != nil else {
300
+                return
301
+            }
302
+
303
+            device.setValue(resolvedMode.rawValue, forKey: "preferredChargingTransportRawValue")
304
+            device.setValue(Date(), forKey: "updatedAt")
305
+
306
+            if let activeSession {
307
+                activeSession.setValue(resolvedMode.rawValue, forKey: "chargingTransportRawValue")
308
+                activeSession.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
309
+                activeSession.setValue(Date(), forKey: "updatedAt")
310
+            }
311
+
312
+            didSave = saveContext()
313
+        }
314
+
315
+        return didSave
316
+    }
317
+
318
+    @discardableResult
319
+    func ensureSession(for snapshot: ChargingMonitorSnapshot, forceStart: Bool) -> Bool {
320
+        var didSave = false
321
+        context.performAndWait {
322
+            guard let resolved = resolvedDeviceObject(for: snapshot.meterMACAddress) else {
323
+                return
324
+            }
325
+
326
+            if fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress) != nil {
327
+                didSave = false
328
+                return
329
+            }
330
+
331
+            let chargingTransportMode = preferredChargingTransportMode(for: resolved)
332
+            let charger = chargingTransportMode == .wireless
333
+                ? resolvedChargerObject(for: snapshot.meterMACAddress)
334
+                : nil
335
+            guard chargingTransportMode == .wired || charger != nil else {
336
+                return
337
+            }
338
+            let stopThreshold = resolvedStopThreshold(
339
+                for: resolved,
340
+                chargingTransportMode: chargingTransportMode,
341
+                fallback: snapshot.fallbackStopThresholdAmps
342
+            )
343
+            guard forceStart || snapshot.currentAmps > stopThreshold else {
344
+                return
345
+            }
346
+
347
+            _ = createSessionObject(
348
+                for: resolved,
349
+                charger: charger,
350
+                snapshot: snapshot,
351
+                stopThreshold: stopThreshold,
352
+                chargingTransportMode: chargingTransportMode
353
+            )
354
+            didSave = saveContext()
355
+        }
356
+        return didSave
357
+    }
358
+
359
+    @discardableResult
360
+    func addBatteryCheckpoint(
361
+        percent: Double,
362
+        label: String?,
363
+        for meterMACAddress: String
364
+    ) -> Bool {
365
+        guard percent.isFinite, percent >= 0, percent <= 100 else {
366
+            return false
367
+        }
368
+
369
+        var didSave = false
370
+        context.performAndWait {
371
+            guard
372
+                let session = fetchActiveSessionObject(forMeterMACAddress: meterMACAddress)
373
+                    ?? fetchLatestSessionObject(forMeterMACAddress: meterMACAddress)
374
+            else {
375
+                return
376
+            }
377
+
378
+            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
379
+        }
380
+        return didSave
381
+    }
382
+
383
+    @discardableResult
384
+    func addBatteryCheckpoint(
385
+        percent: Double,
386
+        label: String?,
387
+        for sessionID: UUID
388
+    ) -> Bool {
389
+        guard percent.isFinite, percent >= 0, percent <= 100 else {
390
+            return false
391
+        }
392
+
393
+        var didSave = false
394
+        context.performAndWait {
395
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
396
+                return
397
+            }
398
+
399
+            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
400
+        }
401
+        return didSave
402
+    }
403
+
404
+    @discardableResult
405
+    func setTargetBatteryPercent(_ percent: Double?, for sessionID: UUID) -> Bool {
406
+        if let percent, (!percent.isFinite || percent <= 0 || percent > 100) {
407
+            return false
408
+        }
409
+
410
+        var didSave = false
411
+        context.performAndWait {
412
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
413
+                return
414
+            }
415
+
416
+            session.setValue(percent, forKey: "targetBatteryPercent")
417
+            session.setValue(nil, forKey: "targetBatteryAlertTriggeredAt")
418
+            session.setValue(Date(), forKey: "updatedAt")
419
+            didSave = saveContext()
420
+        }
421
+        return didSave
422
+    }
423
+
424
+    @discardableResult
425
+    func confirmCompletion(for sessionID: UUID) -> Bool {
426
+        var didSave = false
427
+        context.performAndWait {
428
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
429
+                return
430
+            }
431
+
432
+            guard statusValue(session, key: "statusRawValue") == .active else {
433
+                return
434
+            }
435
+
436
+            let endedAt = dateValue(session, key: "lastObservedAt") ?? Date()
437
+            session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
438
+            session.setValue(endedAt, forKey: "endedAt")
439
+            session.setValue(optionalDoubleValue(session, key: "lastObservedCurrentAmps"), forKey: "completionCurrentAmps")
440
+            clearCompletionConfirmationState(for: session)
441
+            updateCapacityEstimate(for: session)
442
+            session.setValue(Date(), forKey: "updatedAt")
443
+
444
+            if saveContext() {
445
+                if let deviceID = stringValue(session, key: "chargedDeviceID") {
446
+                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
447
+                    didSave = saveContext()
448
+                } else {
449
+                    didSave = true
450
+                }
451
+            }
452
+        }
453
+        return didSave
454
+    }
455
+
456
+    @discardableResult
457
+    func continueMonitoringDespiteCompletionContradiction(for sessionID: UUID) -> Bool {
458
+        var didSave = false
459
+        context.performAndWait {
460
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
461
+                return
462
+            }
463
+
464
+            guard statusValue(session, key: "statusRawValue") == .active else {
465
+                return
466
+            }
467
+
468
+            clearCompletionConfirmationState(for: session)
469
+            session.setValue(Date().addingTimeInterval(completionConfirmationCooldown), forKey: "completionConfirmationCooldownUntil")
470
+            session.setValue(Date(), forKey: "updatedAt")
471
+            didSave = saveContext()
472
+        }
473
+        return didSave
474
+    }
475
+
476
+    @discardableResult
477
+    func deleteChargeSession(id sessionID: UUID) -> Bool {
478
+        var didSave = false
479
+        context.performAndWait {
480
+            guard let session = fetchSessionObject(id: sessionID.uuidString) else {
481
+                return
482
+            }
483
+
484
+            let chargedDeviceID = stringValue(session, key: "chargedDeviceID")
485
+
486
+            fetchCheckpointObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
487
+            fetchSessionSampleObjects(forSessionID: sessionID.uuidString).forEach(context.delete)
488
+            context.delete(session)
489
+
490
+            guard saveContext() else {
491
+                return
492
+            }
493
+
494
+            if let chargedDeviceID {
495
+                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
496
+                didSave = saveContext()
497
+            } else {
498
+                didSave = true
499
+            }
500
+        }
501
+        return didSave
502
+    }
503
+
504
+    @discardableResult
505
+    func deleteChargedDevice(id chargedDeviceID: UUID) -> Bool {
506
+        var didSave = false
507
+
508
+        context.performAndWait {
509
+            guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID.uuidString) else {
510
+                return
511
+            }
512
+
513
+            let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "")
514
+            let deviceSessions = fetchSessions(forChargedDeviceID: chargedDeviceID.uuidString)
515
+            let linkedWirelessSessions = fetchSessions(forChargerID: chargedDeviceID.uuidString)
516
+
517
+            var impactedChargedDeviceIDs = Set<String>()
518
+
519
+            for session in deviceSessions {
520
+                if let impactedID = stringValue(session, key: "chargedDeviceID") {
521
+                    impactedChargedDeviceIDs.insert(impactedID)
522
+                }
523
+                if let impactedChargerID = stringValue(session, key: "chargerID") {
524
+                    impactedChargedDeviceIDs.insert(impactedChargerID)
525
+                }
526
+                if let sessionID = stringValue(session, key: "id") {
527
+                    fetchCheckpointObjects(forSessionID: sessionID).forEach(context.delete)
528
+                    fetchSessionSampleObjects(forSessionID: sessionID).forEach(context.delete)
529
+                }
530
+                context.delete(session)
531
+            }
532
+
533
+            if deviceClass == .charger {
534
+                for session in linkedWirelessSessions {
535
+                    guard stringValue(session, key: "chargedDeviceID") != chargedDeviceID.uuidString else {
536
+                        continue
537
+                    }
538
+                    if let impactedID = stringValue(session, key: "chargedDeviceID") {
539
+                        impactedChargedDeviceIDs.insert(impactedID)
540
+                    }
541
+                    session.setValue(nil, forKey: "chargerID")
542
+                    session.setValue(Date(), forKey: "updatedAt")
543
+                }
544
+            }
545
+
546
+            context.delete(chargedDevice)
547
+
548
+            guard saveContext() else {
549
+                return
550
+            }
551
+
552
+            impactedChargedDeviceIDs.remove(chargedDeviceID.uuidString)
553
+            for impactedID in impactedChargedDeviceIDs {
554
+                refreshDerivedMetrics(forChargedDeviceID: impactedID)
555
+            }
556
+            didSave = saveContext()
557
+        }
558
+
559
+        return didSave
560
+    }
561
+
562
+    @discardableResult
563
+    func observe(snapshot: ChargingMonitorSnapshot) -> Bool {
564
+        var didSave = false
565
+
566
+        context.performAndWait {
567
+            let activeSession = fetchActiveSessionObject(forMeterMACAddress: snapshot.meterMACAddress)
568
+            let resolvedDevice = activeSession.flatMap {
569
+                stringValue($0, key: "chargedDeviceID").flatMap(fetchChargedDeviceObject(id:))
570
+            } ?? resolvedDeviceObject(for: snapshot.meterMACAddress)
571
+
572
+            guard let resolvedDevice else {
573
+                return
574
+            }
575
+
576
+            let chargingTransportMode = activeSession.map { self.chargingTransportMode(for: $0) }
577
+                ?? preferredChargingTransportMode(for: resolvedDevice)
578
+            let charger = chargingTransportMode == .wireless
579
+                ? (activeSession.flatMap { stringValue($0, key: "chargerID") }.flatMap(fetchChargedDeviceObject(id:))
580
+                    ?? resolvedChargerObject(for: snapshot.meterMACAddress))
581
+                : nil
582
+            guard chargingTransportMode == .wired || charger != nil else {
583
+                return
584
+            }
585
+            let stopThreshold = resolvedStopThreshold(
586
+                for: resolvedDevice,
587
+                chargingTransportMode: chargingTransportMode,
588
+                fallback: snapshot.fallbackStopThresholdAmps
589
+            )
590
+            let session = activeSession ?? {
591
+                guard snapshot.currentAmps > stopThreshold else {
592
+                    return nil
593
+                }
594
+                return createSessionObject(
595
+                    for: resolvedDevice,
596
+                    charger: charger,
597
+                    snapshot: snapshot,
598
+                    stopThreshold: stopThreshold,
599
+                    chargingTransportMode: chargingTransportMode
600
+                )
601
+            }()
602
+
603
+            guard let session else {
604
+                return
605
+            }
606
+
607
+            update(session: session, with: snapshot, stopThreshold: stopThreshold)
608
+            updateAggregatedSample(session: session, with: snapshot)
609
+
610
+            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
611
+            guard saveReason != .none else {
612
+                return
613
+            }
614
+
615
+            session.setValue(snapshot.observedAt, forKey: "updatedAt")
616
+
617
+            if saveContext() {
618
+                if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
619
+                    refreshDerivedMetrics(forChargedDeviceID: deviceID)
620
+                    didSave = saveContext()
621
+                } else {
622
+                    didSave = true
623
+                }
624
+            }
625
+        }
626
+
627
+        return didSave
628
+    }
629
+
630
+    func fetchChargedDeviceSummaries() -> [ChargedDeviceSummary] {
631
+        var summaries: [ChargedDeviceSummary] = []
632
+
633
+        context.performAndWait {
634
+            let devices = fetchObjects(entityName: EntityName.chargedDevice)
635
+            let sessions = fetchObjects(entityName: EntityName.chargeSession)
636
+            let checkpoints = fetchObjects(entityName: EntityName.chargeCheckpoint)
637
+            let sessionSamples = fetchObjects(entityName: EntityName.chargeSessionSample)
638
+
639
+            let checkpointsBySessionID = Dictionary(grouping: checkpoints) { stringValue($0, key: "sessionID") ?? "" }
640
+            let samplesBySessionID = Dictionary(grouping: sessionSamples) { stringValue($0, key: "sessionID") ?? "" }
641
+            let sessionsByDeviceID = Dictionary(grouping: sessions) { stringValue($0, key: "chargedDeviceID") ?? "" }
642
+            let sessionsByChargerID = Dictionary(grouping: sessions) { stringValue($0, key: "chargerID") ?? "" }
643
+
644
+            summaries = devices.compactMap { device in
645
+                guard
646
+                    let id = uuidValue(device, key: "id"),
647
+                    let name = stringValue(device, key: "name"),
648
+                    let qrIdentifier = stringValue(device, key: "qrIdentifier"),
649
+                    let rawClass = stringValue(device, key: "deviceClassRawValue"),
650
+                    let deviceClass = ChargedDeviceClass(rawValue: rawClass)
651
+                else {
652
+                    return nil
653
+                }
654
+
655
+                let sessionObjects = relevantSessionObjects(
656
+                    for: id.uuidString,
657
+                    deviceClass: deviceClass,
658
+                    sessionsByDeviceID: sessionsByDeviceID,
659
+                    sessionsByChargerID: sessionsByChargerID
660
+                )
661
+                let sessionSummaries = sessionObjects
662
+                    .compactMap { session in
663
+                        makeSessionSummary(
664
+                            from: session,
665
+                            checkpoints: checkpointsBySessionID[stringValue(session, key: "id") ?? ""] ?? [],
666
+                            samples: samplesBySessionID[stringValue(session, key: "id") ?? ""] ?? []
667
+                        )
668
+                    }
669
+                    .sorted { lhs, rhs in
670
+                        if lhs.status == .active && rhs.status != .active {
671
+                            return true
672
+                        }
673
+                        if lhs.status != .active && rhs.status == .active {
674
+                            return false
675
+                        }
676
+                        return lhs.startedAt > rhs.startedAt
677
+                    }
678
+
679
+                return ChargedDeviceSummary(
680
+                    id: id,
681
+                    qrIdentifier: qrIdentifier,
682
+                    name: name,
683
+                    deviceClass: deviceClass,
684
+                    supportsChargingWhileOff: boolValue(device, key: "supportsChargingWhileOff"),
685
+                    supportsWiredCharging: supportsWiredCharging(for: device),
686
+                    supportsWirelessCharging: supportsWirelessCharging(for: device),
687
+                    preferredChargingTransportMode: preferredChargingTransportMode(for: device),
688
+                    wirelessChargingProfile: wirelessChargingProfile(for: device),
689
+                    wirelessChargerEfficiencyFactor: optionalDoubleValue(device, key: "wirelessChargerEfficiencyFactor"),
690
+                    wiredChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wiredChargeCompletionCurrentAmps"),
691
+                    wirelessChargeCompletionCurrentAmps: optionalDoubleValue(device, key: "wirelessChargeCompletionCurrentAmps"),
692
+                    chargerObservedVoltageSelections: decodedObservedVoltageSelections(from: device),
693
+                    chargerIdleCurrentAmps: optionalDoubleValue(device, key: "chargerIdleCurrentAmps"),
694
+                    chargerEfficiencyFactor: optionalDoubleValue(device, key: "chargerEfficiencyFactor"),
695
+                    chargerMaximumPowerWatts: optionalDoubleValue(device, key: "chargerMaximumPowerWatts"),
696
+                    notes: stringValue(device, key: "notes"),
697
+                    minimumCurrentAmps: optionalDoubleValue(device, key: "minimumCurrentAmps"),
698
+                    estimatedBatteryCapacityWh: optionalDoubleValue(device, key: "estimatedBatteryCapacityWh"),
699
+                    wiredMinimumCurrentAmps: optionalDoubleValue(device, key: "wiredMinimumCurrentAmps"),
700
+                    wirelessMinimumCurrentAmps: optionalDoubleValue(device, key: "wirelessMinimumCurrentAmps"),
701
+                    wiredEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wiredEstimatedBatteryCapacityWh"),
702
+                    wirelessEstimatedBatteryCapacityWh: optionalDoubleValue(device, key: "wirelessEstimatedBatteryCapacityWh"),
703
+                    lastAssociatedMeterMAC: stringValue(device, key: "lastAssociatedMeterMAC"),
704
+                    createdAt: dateValue(device, key: "createdAt") ?? .distantPast,
705
+                    updatedAt: dateValue(device, key: "updatedAt") ?? .distantPast,
706
+                    sessions: sessionSummaries,
707
+                    capacityHistory: buildCapacityHistory(from: sessionSummaries),
708
+                    typicalCurve: buildTypicalCurve(from: sessionSummaries)
709
+                )
710
+            }
711
+            .sorted { lhs, rhs in
712
+                if lhs.activeSession != nil && rhs.activeSession == nil {
713
+                    return true
714
+                }
715
+                if lhs.activeSession == nil && rhs.activeSession != nil {
716
+                    return false
717
+                }
718
+                if lhs.updatedAt != rhs.updatedAt {
719
+                    return lhs.updatedAt > rhs.updatedAt
720
+                }
721
+                return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
722
+            }
723
+        }
724
+
725
+        return summaries
726
+    }
727
+
728
+    func resolvedChargedDeviceSummary(forMeterMACAddress meterMACAddress: String) -> ChargedDeviceSummary? {
729
+        let normalizedMAC = normalizedMACAddress(meterMACAddress)
730
+        guard !normalizedMAC.isEmpty else { return nil }
731
+
732
+        let summaries = fetchChargedDeviceSummaries().filter { !$0.isCharger }
733
+
734
+        if let activeMatch = summaries.first(where: { summary in
735
+            summary.activeSession?.meterMACAddress == normalizedMAC
736
+        }) {
737
+            return activeMatch
738
+        }
739
+
740
+        return summaries.first(where: { $0.lastAssociatedMeterMAC == normalizedMAC })
741
+    }
742
+
743
+    func activeChargeSessionSummary(forMeterMACAddress meterMACAddress: String) -> ChargeSessionSummary? {
744
+        let normalizedMAC = normalizedMACAddress(meterMACAddress)
745
+        guard !normalizedMAC.isEmpty else { return nil }
746
+
747
+        return fetchChargedDeviceSummaries()
748
+            .flatMap(\.sessions)
749
+            .first(where: {
750
+                $0.status == .active && $0.meterMACAddress == normalizedMAC
751
+            })
752
+    }
753
+
754
+    private func createSessionObject(
755
+        for chargedDevice: NSManagedObject,
756
+        charger: NSManagedObject?,
757
+        snapshot: ChargingMonitorSnapshot,
758
+        stopThreshold: Double,
759
+        chargingTransportMode: ChargingTransportMode
760
+    ) -> NSManagedObject? {
761
+        guard
762
+            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context),
763
+            let chargedDeviceID = stringValue(chargedDevice, key: "id")
764
+        else {
765
+            return nil
766
+        }
767
+
768
+        let session = NSManagedObject(entity: entity, insertInto: context)
769
+        let now = snapshot.observedAt
770
+        session.setValue(UUID().uuidString, forKey: "id")
771
+        session.setValue(chargedDeviceID, forKey: "chargedDeviceID")
772
+        session.setValue(charger.flatMap { stringValue($0, key: "id") }, forKey: "chargerID")
773
+        session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress")
774
+        session.setValue(snapshot.meterName, forKey: "meterName")
775
+        session.setValue(snapshot.meterModel, forKey: "meterModel")
776
+        session.setValue(now, forKey: "startedAt")
777
+        session.setValue(now, forKey: "lastObservedAt")
778
+        session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue")
779
+        session.setValue(ChargeSessionSourceMode.live.rawValue, forKey: "sourceModeRawValue")
780
+        session.setValue(chargingTransportMode.rawValue, forKey: "chargingTransportRawValue")
781
+        session.setValue(stopThreshold, forKey: "stopThresholdAmps")
782
+        session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts")
783
+        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
784
+        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
785
+        session.setValue(
786
+            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
787
+            forKey: "lastObservedVoltageVolts"
788
+        )
789
+        session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps")
790
+        session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps")
791
+        session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts")
792
+        session.setValue(
793
+            chargingTransportMode == .wired ? snapshot.voltageVolts : nil,
794
+            forKey: "maximumObservedVoltageVolts"
795
+        )
796
+        session.setValue(boolValue(chargedDevice, key: "supportsChargingWhileOff"), forKey: "supportsChargingWhileOff")
797
+        if let selectedDataGroup = snapshot.selectedDataGroup {
798
+            session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup")
799
+        }
800
+        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
801
+            session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
802
+            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
803
+        }
804
+        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
805
+            session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
806
+            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
807
+        }
808
+        session.setValue(now, forKey: "createdAt")
809
+        session.setValue(now, forKey: "updatedAt")
810
+
811
+        chargedDevice.setValue(snapshot.meterMACAddress, forKey: "lastAssociatedMeterMAC")
812
+        chargedDevice.setValue(chargingTransportMode.rawValue, forKey: "preferredChargingTransportRawValue")
813
+        chargedDevice.setValue(now, forKey: "updatedAt")
814
+        return session
815
+    }
816
+
817
+    private func update(
818
+        session: NSManagedObject,
819
+        with snapshot: ChargingMonitorSnapshot,
820
+        stopThreshold: Double
821
+    ) {
822
+        let sessionChargingTransportMode = chargingTransportMode(for: session)
823
+        let lastObservedAt = dateValue(session, key: "lastObservedAt")
824
+        let previousPower = doubleValue(session, key: "lastObservedPowerWatts")
825
+        let previousCurrent = doubleValue(session, key: "lastObservedCurrentAmps")
826
+        var measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
827
+        var measuredChargeAh = doubleValue(session, key: "measuredChargeAh")
828
+        var sourceMode = ChargeSessionSourceMode(rawValue: stringValue(session, key: "sourceModeRawValue") ?? "") ?? .live
829
+        var usedOfflineMeterCounters = boolValue(session, key: "usedOfflineMeterCounters")
830
+
831
+        if let lastObservedAt {
832
+            let deltaSeconds = max(snapshot.observedAt.timeIntervalSince(lastObservedAt), 0)
833
+            if deltaSeconds > 0, deltaSeconds <= maximumLiveIntegrationGap {
834
+                measuredEnergyWh += max(previousPower, 0) * deltaSeconds / 3600
835
+                measuredChargeAh += max(previousCurrent, 0) * deltaSeconds / 3600
836
+                if sourceMode == .offline {
837
+                    sourceMode = .blended
838
+                }
839
+            }
840
+        }
841
+
842
+        if let counterGroup = snapshot.selectedDataGroup,
843
+           let storedGroup = optionalInt16Value(session, key: "selectedDataGroup"),
844
+           UInt8(storedGroup) != counterGroup {
845
+            session.setValue(Int16(counterGroup), forKey: "selectedDataGroup")
846
+            session.setValue(snapshot.meterEnergyCounterWh, forKey: "meterEnergyBaselineWh")
847
+            session.setValue(snapshot.meterChargeCounterAh, forKey: "meterChargeBaselineAh")
848
+        }
849
+
850
+        if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
851
+            let baselineEnergy = optionalDoubleValue(session, key: "meterEnergyBaselineWh") ?? meterEnergyCounterWh
852
+            let lastEnergy = optionalDoubleValue(session, key: "meterLastEnergyWh")
853
+            if optionalDoubleValue(session, key: "meterEnergyBaselineWh") == nil {
854
+                session.setValue(baselineEnergy, forKey: "meterEnergyBaselineWh")
855
+            }
856
+
857
+            if meterEnergyCounterWh + counterDecreaseTolerance >= baselineEnergy {
858
+                let offlineEnergy = meterEnergyCounterWh - baselineEnergy
859
+                if offlineEnergy > measuredEnergyWh {
860
+                    measuredEnergyWh = offlineEnergy
861
+                }
862
+                usedOfflineMeterCounters = true
863
+                sourceMode = sourceMode == .live && measuredEnergyWh > 0 ? .blended : .offline
864
+            } else if let lastEnergy, meterEnergyCounterWh > lastEnergy {
865
+                let delta = meterEnergyCounterWh - lastEnergy
866
+                if delta > 0 {
867
+                    measuredEnergyWh += delta
868
+                    usedOfflineMeterCounters = true
869
+                    sourceMode = .blended
870
+                }
871
+            }
872
+            session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh")
873
+        }
874
+
875
+        if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
876
+            let baselineCharge = optionalDoubleValue(session, key: "meterChargeBaselineAh") ?? meterChargeCounterAh
877
+            let lastCharge = optionalDoubleValue(session, key: "meterLastChargeAh")
878
+            if optionalDoubleValue(session, key: "meterChargeBaselineAh") == nil {
879
+                session.setValue(baselineCharge, forKey: "meterChargeBaselineAh")
880
+            }
881
+
882
+            if meterChargeCounterAh + counterDecreaseTolerance >= baselineCharge {
883
+                let offlineCharge = meterChargeCounterAh - baselineCharge
884
+                if offlineCharge > measuredChargeAh {
885
+                    measuredChargeAh = offlineCharge
886
+                }
887
+                usedOfflineMeterCounters = true
888
+            } else if let lastCharge, meterChargeCounterAh > lastCharge {
889
+                let delta = meterChargeCounterAh - lastCharge
890
+                if delta > 0 {
891
+                    measuredChargeAh += delta
892
+                    usedOfflineMeterCounters = true
893
+                }
894
+            }
895
+            session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
896
+        }
897
+
898
+        let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
899
+        let updatedMinimum: Double
900
+        if snapshot.currentAmps > 0 {
901
+            updatedMinimum = min(existingMinimum ?? snapshot.currentAmps, snapshot.currentAmps)
902
+        } else {
903
+            updatedMinimum = existingMinimum ?? 0
904
+        }
905
+
906
+        session.setValue(measuredEnergyWh, forKey: "measuredEnergyWh")
907
+        session.setValue(measuredChargeAh, forKey: "measuredChargeAh")
908
+        session.setValue(updatedMinimum, forKey: "minimumObservedCurrentAmps")
909
+        session.setValue(stopThreshold, forKey: "stopThresholdAmps")
910
+        session.setValue(snapshot.observedAt, forKey: "lastObservedAt")
911
+        session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps")
912
+        session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts")
913
+        session.setValue(
914
+            sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil,
915
+            forKey: "lastObservedVoltageVolts"
916
+        )
917
+        session.setValue(
918
+            max(optionalDoubleValue(session, key: "maximumObservedCurrentAmps") ?? snapshot.currentAmps, snapshot.currentAmps),
919
+            forKey: "maximumObservedCurrentAmps"
920
+        )
921
+        session.setValue(
922
+            max(optionalDoubleValue(session, key: "maximumObservedPowerWatts") ?? snapshot.powerWatts, snapshot.powerWatts),
923
+            forKey: "maximumObservedPowerWatts"
924
+        )
925
+        session.setValue(
926
+            sessionChargingTransportMode == .wired
927
+                ? max(optionalDoubleValue(session, key: "maximumObservedVoltageVolts") ?? snapshot.voltageVolts, snapshot.voltageVolts)
928
+                : nil,
929
+            forKey: "maximumObservedVoltageVolts"
930
+        )
931
+        session.setValue(usedOfflineMeterCounters, forKey: "usedOfflineMeterCounters")
932
+        session.setValue(sourceMode.rawValue, forKey: "sourceModeRawValue")
933
+        maybeTriggerTargetBatteryAlert(for: session, observedAt: snapshot.observedAt)
934
+
935
+        if snapshot.currentAmps <= stopThreshold {
936
+            let belowThresholdSince = dateValue(session, key: "belowThresholdSince") ?? snapshot.observedAt
937
+            session.setValue(belowThresholdSince, forKey: "belowThresholdSince")
938
+            if snapshot.observedAt.timeIntervalSince(belowThresholdSince) >= stopDetectionHoldDuration {
939
+                if boolValue(session, key: "requiresCompletionConfirmation") {
940
+                    // Leave the session active until the user explicitly confirms or charging resumes.
941
+                    return
942
+                }
943
+
944
+                if shouldRequireCompletionConfirmation(for: session, observedAt: snapshot.observedAt) {
945
+                    requestCompletionConfirmation(for: session, observedAt: snapshot.observedAt)
946
+                } else {
947
+                    session.setValue(ChargeSessionStatus.completed.rawValue, forKey: "statusRawValue")
948
+                    session.setValue(snapshot.observedAt, forKey: "endedAt")
949
+                    session.setValue(snapshot.currentAmps, forKey: "completionCurrentAmps")
950
+                    updateCapacityEstimate(for: session)
951
+                }
952
+            }
953
+        } else {
954
+            session.setValue(nil, forKey: "belowThresholdSince")
955
+            clearCompletionConfirmationState(for: session)
956
+            session.setValue(nil, forKey: "completionConfirmationCooldownUntil")
957
+        }
958
+    }
959
+
960
+    private func updateAggregatedSample(
961
+        session: NSManagedObject,
962
+        with snapshot: ChargingMonitorSnapshot
963
+    ) {
964
+        guard
965
+            let sessionID = stringValue(session, key: "id"),
966
+            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
967
+            let startedAt = dateValue(session, key: "startedAt"),
968
+            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSessionSample, in: context)
969
+        else {
970
+            return
971
+        }
972
+
973
+        let elapsed = max(snapshot.observedAt.timeIntervalSince(startedAt), 0)
974
+        let bucketIndex = Int32(floor(elapsed / Self.aggregatedSampleBucketDuration))
975
+        let bucketStart = startedAt.addingTimeInterval(Double(bucketIndex) * Self.aggregatedSampleBucketDuration)
976
+        let bucketIdentifier = "\(sessionID)-\(bucketIndex)"
977
+        let sample = fetchSessionSampleObject(sessionID: sessionID, bucketIndex: bucketIndex)
978
+            ?? NSManagedObject(entity: entity, insertInto: context)
979
+        let sessionChargingTransportMode = chargingTransportMode(for: session)
980
+        let sampleVoltage = sessionChargingTransportMode == .wired ? snapshot.voltageVolts : nil
981
+
982
+        let existingCount = max(optionalInt16Value(sample, key: "sampleCount") ?? 0, 0)
983
+        let updatedCount = existingCount + 1
984
+
985
+        sample.setValue(bucketIdentifier, forKey: "id")
986
+        sample.setValue(sessionID, forKey: "sessionID")
987
+        sample.setValue(chargedDeviceID, forKey: "chargedDeviceID")
988
+        sample.setValue(bucketIndex, forKey: "bucketIndex")
989
+        sample.setValue(max(snapshot.observedAt, bucketStart), forKey: "timestamp")
990
+        sample.setValue(
991
+            runningAverage(
992
+                currentAverage: optionalDoubleValue(sample, key: "averageCurrentAmps") ?? snapshot.currentAmps,
993
+                currentCount: Int(existingCount),
994
+                newValue: snapshot.currentAmps
995
+            ),
996
+            forKey: "averageCurrentAmps"
997
+        )
998
+        sample.setValue(
999
+            sampleVoltage.flatMap { voltage in
1000
+                runningAverage(
1001
+                    currentAverage: optionalDoubleValue(sample, key: "averageVoltageVolts") ?? voltage,
1002
+                    currentCount: Int(existingCount),
1003
+                    newValue: voltage
1004
+                )
1005
+            },
1006
+            forKey: "averageVoltageVolts"
1007
+        )
1008
+        sample.setValue(
1009
+            runningAverage(
1010
+                currentAverage: optionalDoubleValue(sample, key: "averagePowerWatts") ?? snapshot.powerWatts,
1011
+                currentCount: Int(existingCount),
1012
+                newValue: snapshot.powerWatts
1013
+            ),
1014
+            forKey: "averagePowerWatts"
1015
+        )
1016
+        sample.setValue(doubleValue(session, key: "measuredEnergyWh"), forKey: "measuredEnergyWh")
1017
+        sample.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1018
+        sample.setValue(Int16(updatedCount), forKey: "sampleCount")
1019
+        sample.setValue(dateValue(sample, key: "createdAt") ?? snapshot.observedAt, forKey: "createdAt")
1020
+        sample.setValue(snapshot.observedAt, forKey: "updatedAt")
1021
+    }
1022
+
1023
+    private func maybeTriggerTargetBatteryAlert(for session: NSManagedObject, observedAt: Date) {
1024
+        guard dateValue(session, key: "targetBatteryAlertTriggeredAt") == nil else {
1025
+            return
1026
+        }
1027
+
1028
+        guard let targetBatteryPercent = optionalDoubleValue(session, key: "targetBatteryPercent") else {
1029
+            return
1030
+        }
1031
+
1032
+        let predictedBatteryPercent = predictedBatteryPercent(for: session)
1033
+            ?? optionalDoubleValue(session, key: "endBatteryPercent")
1034
+
1035
+        guard let predictedBatteryPercent, predictedBatteryPercent >= targetBatteryPercent else {
1036
+            return
1037
+        }
1038
+
1039
+        session.setValue(observedAt, forKey: "targetBatteryAlertTriggeredAt")
1040
+    }
1041
+
1042
+    private func shouldRequireCompletionConfirmation(
1043
+        for session: NSManagedObject,
1044
+        observedAt: Date
1045
+    ) -> Bool {
1046
+        if let cooldownUntil = dateValue(session, key: "completionConfirmationCooldownUntil"),
1047
+           cooldownUntil > observedAt {
1048
+            return false
1049
+        }
1050
+
1051
+        guard let predictedBatteryPercent = predictedBatteryPercent(for: session) else {
1052
+            return false
1053
+        }
1054
+
1055
+        let expectedCompletionPercent = optionalDoubleValue(session, key: "targetBatteryPercent")
1056
+            ?? defaultCompletionPercentThreshold
1057
+
1058
+        return predictedBatteryPercent + completionContradictionTolerancePercent < expectedCompletionPercent
1059
+    }
1060
+
1061
+    private func requestCompletionConfirmation(for session: NSManagedObject, observedAt: Date) {
1062
+        guard !boolValue(session, key: "requiresCompletionConfirmation") else {
1063
+            return
1064
+        }
1065
+
1066
+        session.setValue(true, forKey: "requiresCompletionConfirmation")
1067
+        session.setValue(observedAt, forKey: "completionConfirmationRequestedAt")
1068
+        session.setValue(predictedBatteryPercent(for: session), forKey: "completionContradictionPercent")
1069
+    }
1070
+
1071
+    private func clearCompletionConfirmationState(for session: NSManagedObject) {
1072
+        session.setValue(false, forKey: "requiresCompletionConfirmation")
1073
+        session.setValue(nil, forKey: "completionConfirmationRequestedAt")
1074
+        session.setValue(nil, forKey: "completionContradictionPercent")
1075
+    }
1076
+
1077
+    private func predictedBatteryPercent(for session: NSManagedObject) -> Double? {
1078
+        guard
1079
+            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1080
+            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID),
1081
+            let estimatedCapacityWh = resolvedEstimatedBatteryCapacityWh(for: session, chargedDevice: chargedDevice),
1082
+            estimatedCapacityWh > 0
1083
+        else {
1084
+            return nil
1085
+        }
1086
+
1087
+        let measuredEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1088
+            ?? doubleValue(session, key: "measuredEnergyWh")
1089
+        let sessionID = stringValue(session, key: "id") ?? ""
1090
+
1091
+        struct Anchor {
1092
+            let percent: Double
1093
+            let energyWh: Double
1094
+        }
1095
+
1096
+        var anchors: [Anchor] = []
1097
+        if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") {
1098
+            anchors.append(Anchor(percent: startBatteryPercent, energyWh: 0))
1099
+        }
1100
+
1101
+        let checkpointAnchors = fetchCheckpointObjects(forSessionID: sessionID)
1102
+            .compactMap(makeCheckpointSummary(from:))
1103
+            .sorted { lhs, rhs in
1104
+                if lhs.measuredEnergyWh != rhs.measuredEnergyWh {
1105
+                    return lhs.measuredEnergyWh < rhs.measuredEnergyWh
1106
+                }
1107
+                return lhs.timestamp < rhs.timestamp
1108
+            }
1109
+            .map { Anchor(percent: $0.batteryPercent, energyWh: $0.measuredEnergyWh) }
1110
+        anchors.append(contentsOf: checkpointAnchors)
1111
+
1112
+        guard !anchors.isEmpty else {
1113
+            return optionalDoubleValue(session, key: "endBatteryPercent")
1114
+        }
1115
+
1116
+        let anchor = anchors.filter { $0.energyWh <= measuredEnergyWh + 0.05 }.last ?? anchors.first!
1117
+        return min(
1118
+            100,
1119
+            max(
1120
+                0,
1121
+                anchor.percent + (((measuredEnergyWh - anchor.energyWh) / estimatedCapacityWh) * 100)
1122
+            )
1123
+        )
1124
+    }
1125
+
1126
+    private func resolvedEstimatedBatteryCapacityWh(
1127
+        for session: NSManagedObject,
1128
+        chargedDevice: NSManagedObject
1129
+    ) -> Double? {
1130
+        if let sessionCapacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"),
1131
+           sessionCapacityEstimate > 0 {
1132
+            return sessionCapacityEstimate
1133
+        }
1134
+
1135
+        switch chargingTransportMode(for: session) {
1136
+        case .wired:
1137
+            return optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1138
+                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1139
+        case .wireless:
1140
+            return optionalDoubleValue(chargedDevice, key: "wirelessEstimatedBatteryCapacityWh")
1141
+                ?? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1142
+        }
1143
+    }
1144
+
1145
+    private func updateCapacityEstimate(for session: NSManagedObject) {
1146
+        guard
1147
+            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1148
+            let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID)
1149
+        else {
1150
+            session.setValue(nil, forKey: "effectiveBatteryEnergyWh")
1151
+            session.setValue(nil, forKey: "capacityEstimateWh")
1152
+            return
1153
+        }
1154
+
1155
+        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1156
+        let chargingMode = chargingTransportMode(for: session)
1157
+        let wirelessResolution = chargingMode == .wireless
1158
+            ? resolvedWirelessEfficiency(for: session, chargedDevice: chargedDevice)
1159
+            : nil
1160
+        let effectiveBatteryEnergyWh = chargingMode == .wired
1161
+            ? measuredEnergyWh
1162
+            : wirelessResolution.map { measuredEnergyWh * $0.factor }
1163
+
1164
+        session.setValue(effectiveBatteryEnergyWh, forKey: "effectiveBatteryEnergyWh")
1165
+        session.setValue(wirelessResolution?.factor, forKey: "wirelessEfficiencyFactor")
1166
+        session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency")
1167
+        session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency")
1168
+
1169
+        guard
1170
+            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1171
+            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1172
+        else {
1173
+            session.setValue(nil, forKey: "capacityEstimateWh")
1174
+            return
1175
+        }
1176
+
1177
+        let percentDelta = endBatteryPercent - startBatteryPercent
1178
+        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1179
+
1180
+        guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
1181
+            session.setValue(nil, forKey: "capacityEstimateWh")
1182
+            return
1183
+        }
1184
+
1185
+        if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
1186
+            session.setValue(nil, forKey: "capacityEstimateWh")
1187
+            return
1188
+        }
1189
+
1190
+        let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100)
1191
+        session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh")
1192
+    }
1193
+
1194
+    @discardableResult
1195
+    private func addBatteryCheckpoint(
1196
+        percent: Double,
1197
+        label: String?,
1198
+        to session: NSManagedObject
1199
+    ) -> Bool {
1200
+        guard
1201
+            let sessionID = stringValue(session, key: "id"),
1202
+            let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1203
+            let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeCheckpoint, in: context)
1204
+        else {
1205
+            return false
1206
+        }
1207
+
1208
+        let checkpoint = NSManagedObject(entity: entity, insertInto: context)
1209
+        let checkpointEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1210
+            ?? doubleValue(session, key: "measuredEnergyWh")
1211
+        checkpoint.setValue(UUID().uuidString, forKey: "id")
1212
+        checkpoint.setValue(sessionID, forKey: "sessionID")
1213
+        checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1214
+        checkpoint.setValue(Date(), forKey: "timestamp")
1215
+        checkpoint.setValue(percent, forKey: "batteryPercent")
1216
+        checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
1217
+        checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1218
+        checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1219
+        checkpoint.setValue(
1220
+            chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
1221
+            forKey: "voltageVolts"
1222
+        )
1223
+        checkpoint.setValue(normalizedOptionalText(label), forKey: "label")
1224
+        checkpoint.setValue(Date(), forKey: "createdAt")
1225
+
1226
+        if session.value(forKey: "startBatteryPercent") == nil {
1227
+            session.setValue(percent, forKey: "startBatteryPercent")
1228
+        }
1229
+        session.setValue(percent, forKey: "endBatteryPercent")
1230
+        session.setValue(Date(), forKey: "updatedAt")
1231
+        updateCapacityEstimate(for: session)
1232
+
1233
+        guard saveContext() else {
1234
+            return false
1235
+        }
1236
+
1237
+        refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
1238
+        return saveContext()
1239
+    }
1240
+
1241
+    private func resolvedWirelessEfficiency(
1242
+        for session: NSManagedObject,
1243
+        chargedDevice: NSManagedObject
1244
+    ) -> (factor: Double, usesEstimated: Bool, shouldWarn: Bool)? {
1245
+        if let storedFactor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"),
1246
+           storedFactor > 0 {
1247
+            return (
1248
+                factor: storedFactor,
1249
+                usesEstimated: boolValue(session, key: "usesEstimatedWirelessEfficiency"),
1250
+                shouldWarn: boolValue(session, key: "shouldWarnAboutLowWirelessEfficiency")
1251
+            )
1252
+        }
1253
+
1254
+        let chargingProfile = wirelessChargingProfile(for: chargedDevice)
1255
+        let measuredEnergyWh = doubleValue(session, key: "measuredEnergyWh")
1256
+        guard measuredEnergyWh > 0 else {
1257
+            return nil
1258
+        }
1259
+
1260
+        if chargingProfile == .magsafe,
1261
+           let calibratedFactor = optionalDoubleValue(chargedDevice, key: "wirelessChargerEfficiencyFactor"),
1262
+           calibratedFactor > 0 {
1263
+            return (factor: calibratedFactor, usesEstimated: false, shouldWarn: false)
1264
+        }
1265
+
1266
+        guard
1267
+            let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"),
1268
+            let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent")
1269
+        else {
1270
+            return nil
1271
+        }
1272
+
1273
+        let percentDelta = endBatteryPercent - startBatteryPercent
1274
+        guard percentDelta >= 20 else {
1275
+            return nil
1276
+        }
1277
+
1278
+        guard let wiredCapacityWh = optionalDoubleValue(chargedDevice, key: "wiredEstimatedBatteryCapacityWh")
1279
+            ?? ((preferredChargingTransportMode(for: chargedDevice) == .wired)
1280
+                ? optionalDoubleValue(chargedDevice, key: "estimatedBatteryCapacityWh")
1281
+                : nil),
1282
+              wiredCapacityWh > 0
1283
+        else {
1284
+            return nil
1285
+        }
1286
+
1287
+        let deliveredBatteryEnergyWh = wiredCapacityWh * (percentDelta / 100)
1288
+        let rawFactor = deliveredBatteryEnergyWh / measuredEnergyWh
1289
+        let clampedFactor = min(max(rawFactor, minimumWirelessEfficiencyFactor), maximumWirelessEfficiencyFactor)
1290
+        let usesEstimated = chargingProfile != .magsafe
1291
+        let shouldWarn = usesEstimated && clampedFactor < lowWirelessEfficiencyThreshold
1292
+
1293
+        return (factor: clampedFactor, usesEstimated: usesEstimated, shouldWarn: shouldWarn)
1294
+    }
1295
+
1296
+    private func refreshDerivedMetrics(forChargedDeviceID chargedDeviceID: String) {
1297
+        guard let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) else {
1298
+            return
1299
+        }
1300
+
1301
+        let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff")
1302
+        let wirelessProfile = wirelessChargingProfile(for: chargedDevice)
1303
+        let deviceClass = ChargedDeviceClass(rawValue: stringValue(chargedDevice, key: "deviceClassRawValue") ?? "") ?? .other
1304
+        let sessions = relevantSessionObjects(
1305
+            for: chargedDeviceID,
1306
+            deviceClass: deviceClass,
1307
+            sessionsByDeviceID: [chargedDeviceID: fetchSessions(forChargedDeviceID: chargedDeviceID)],
1308
+            sessionsByChargerID: [chargedDeviceID: fetchSessions(forChargerID: chargedDeviceID)]
1309
+        )
1310
+        let wiredMinimumCurrent = derivedMinimumCurrent(
1311
+            from: sessions,
1312
+            chargingTransportMode: .wired
1313
+        )
1314
+        let wirelessMinimumCurrent = derivedMinimumCurrent(
1315
+            from: sessions,
1316
+            chargingTransportMode: .wireless
1317
+        )
1318
+
1319
+        let wiredCapacity = derivedCapacity(
1320
+            from: sessions,
1321
+            chargingTransportMode: .wired,
1322
+            supportsChargingWhileOff: supportsChargingWhileOff
1323
+        )
1324
+        let wirelessCapacity = derivedCapacity(
1325
+            from: sessions,
1326
+            chargingTransportMode: .wireless,
1327
+            supportsChargingWhileOff: supportsChargingWhileOff
1328
+        )
1329
+        let wirelessEfficiency = derivedWirelessEfficiency(
1330
+            from: sessions,
1331
+            chargingProfile: wirelessProfile
1332
+        )
1333
+        let configuredWiredCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1334
+        let configuredWirelessCompletionCurrent = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1335
+        let chargerObservedVoltages = deviceClass == .charger ? derivedObservedVoltageSelections(from: sessions) : []
1336
+        let chargerIdleCurrent = deviceClass == .charger ? derivedIdleCurrent(from: sessions) : nil
1337
+        let chargerEfficiency = deviceClass == .charger ? derivedChargerEfficiency(from: sessions) : nil
1338
+        let chargerMaximumPower = deviceClass == .charger ? derivedMaximumPower(from: sessions) : nil
1339
+
1340
+        let preferredChargingTransportMode = preferredChargingTransportMode(for: chargedDevice)
1341
+        let preferredMinimumCurrent: Double?
1342
+        let preferredCapacity: Double?
1343
+        switch preferredChargingTransportMode {
1344
+        case .wired:
1345
+            preferredMinimumCurrent = configuredWiredCompletionCurrent ?? wiredMinimumCurrent ?? configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent
1346
+            preferredCapacity = wiredCapacity ?? wirelessCapacity
1347
+        case .wireless:
1348
+            preferredMinimumCurrent = configuredWirelessCompletionCurrent ?? wirelessMinimumCurrent ?? configuredWiredCompletionCurrent ?? wiredMinimumCurrent
1349
+            preferredCapacity = wirelessCapacity ?? wiredCapacity
1350
+        }
1351
+
1352
+        chargedDevice.setValue(wiredMinimumCurrent, forKey: "wiredMinimumCurrentAmps")
1353
+        chargedDevice.setValue(wirelessMinimumCurrent, forKey: "wirelessMinimumCurrentAmps")
1354
+        chargedDevice.setValue(wiredCapacity, forKey: "wiredEstimatedBatteryCapacityWh")
1355
+        chargedDevice.setValue(wirelessCapacity, forKey: "wirelessEstimatedBatteryCapacityWh")
1356
+        chargedDevice.setValue(wirelessEfficiency, forKey: "wirelessChargerEfficiencyFactor")
1357
+        chargedDevice.setValue(encodedObservedVoltageSelections(chargerObservedVoltages), forKey: "chargerObservedVoltageSelectionsRawValue")
1358
+        chargedDevice.setValue(chargerIdleCurrent, forKey: "chargerIdleCurrentAmps")
1359
+        chargedDevice.setValue(chargerEfficiency, forKey: "chargerEfficiencyFactor")
1360
+        chargedDevice.setValue(chargerMaximumPower, forKey: "chargerMaximumPowerWatts")
1361
+        chargedDevice.setValue(preferredMinimumCurrent, forKey: "minimumCurrentAmps")
1362
+        chargedDevice.setValue(preferredCapacity, forKey: "estimatedBatteryCapacityWh")
1363
+        chargedDevice.setValue(Date(), forKey: "updatedAt")
1364
+    }
1365
+
1366
+    private func buildCapacityHistory(from sessions: [ChargeSessionSummary]) -> [CapacityTrendPoint] {
1367
+        sessions
1368
+            .filter { $0.status == .completed }
1369
+            .compactMap { session in
1370
+                guard let capacityEstimateWh = session.capacityEstimateWh else { return nil }
1371
+                let timestamp = session.endedAt ?? session.lastObservedAt
1372
+                return CapacityTrendPoint(
1373
+                    sessionID: session.id,
1374
+                    timestamp: timestamp,
1375
+                    capacityWh: capacityEstimateWh,
1376
+                    chargingTransportMode: session.chargingTransportMode
1377
+                )
1378
+            }
1379
+            .sorted { $0.timestamp < $1.timestamp }
1380
+    }
1381
+
1382
+    private func buildTypicalCurve(from sessions: [ChargeSessionSummary]) -> [TypicalChargeCurvePoint] {
1383
+        var groupedEnergyByBin: [Int: [Double]] = [:]
1384
+        var groupedChargeByBin: [Int: [Double]] = [:]
1385
+
1386
+        for session in sessions where session.status == .completed {
1387
+            var points = session.checkpoints
1388
+
1389
+            if let startBatteryPercent = session.startBatteryPercent {
1390
+                points.append(
1391
+                    ChargeCheckpointSummary(
1392
+                        id: UUID(),
1393
+                        sessionID: session.id,
1394
+                        chargedDeviceID: session.chargedDeviceID,
1395
+                        timestamp: session.startedAt,
1396
+                        batteryPercent: startBatteryPercent,
1397
+                        measuredEnergyWh: 0,
1398
+                        measuredChargeAh: 0,
1399
+                        currentAmps: 0,
1400
+                        voltageVolts: nil,
1401
+                        label: "Start"
1402
+                    )
1403
+                )
1404
+            }
1405
+
1406
+            if let endBatteryPercent = session.endBatteryPercent {
1407
+                points.append(
1408
+                    ChargeCheckpointSummary(
1409
+                        id: UUID(),
1410
+                        sessionID: session.id,
1411
+                        chargedDeviceID: session.chargedDeviceID,
1412
+                        timestamp: session.endedAt ?? session.lastObservedAt,
1413
+                        batteryPercent: endBatteryPercent,
1414
+                        measuredEnergyWh: session.effectiveBatteryEnergyWh ?? session.measuredEnergyWh,
1415
+                        measuredChargeAh: session.measuredChargeAh,
1416
+                        currentAmps: 0,
1417
+                        voltageVolts: nil,
1418
+                        label: "End"
1419
+                    )
1420
+                )
1421
+            }
1422
+
1423
+            for point in points {
1424
+                let percentBin = Int((point.batteryPercent / 10).rounded(.toNearestOrEven)) * 10
1425
+                groupedEnergyByBin[percentBin, default: []].append(point.measuredEnergyWh)
1426
+                groupedChargeByBin[percentBin, default: []].append(point.measuredChargeAh)
1427
+            }
1428
+        }
1429
+
1430
+        return groupedEnergyByBin.keys.sorted().compactMap { percentBin in
1431
+            guard
1432
+                let energies = groupedEnergyByBin[percentBin],
1433
+                let charges = groupedChargeByBin[percentBin],
1434
+                !energies.isEmpty,
1435
+                !charges.isEmpty
1436
+            else {
1437
+                return nil
1438
+            }
1439
+
1440
+            return TypicalChargeCurvePoint(
1441
+                percentBin: percentBin,
1442
+                averageEnergyWh: energies.reduce(0, +) / Double(energies.count),
1443
+                averageChargeAh: charges.reduce(0, +) / Double(charges.count),
1444
+                sampleCount: min(energies.count, charges.count)
1445
+            )
1446
+        }
1447
+    }
1448
+
1449
+    private func makeSessionSummary(
1450
+        from object: NSManagedObject,
1451
+        checkpoints: [NSManagedObject],
1452
+        samples: [NSManagedObject]
1453
+    ) -> ChargeSessionSummary? {
1454
+        let chargingTransportMode = chargingTransportMode(for: object)
1455
+
1456
+        guard
1457
+            let id = uuidValue(object, key: "id"),
1458
+            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1459
+            let startedAt = dateValue(object, key: "startedAt"),
1460
+            let lastObservedAt = dateValue(object, key: "lastObservedAt"),
1461
+            let status = statusValue(object, key: "statusRawValue"),
1462
+            let sourceMode = ChargeSessionSourceMode(rawValue: stringValue(object, key: "sourceModeRawValue") ?? "")
1463
+        else {
1464
+            return nil
1465
+        }
1466
+
1467
+        let checkpointSummaries = checkpoints.compactMap(makeCheckpointSummary(from:))
1468
+            .sorted { $0.timestamp < $1.timestamp }
1469
+        let sampleSummaries = samples.compactMap(makeChargeSessionSampleSummary(from:))
1470
+            .sorted { lhs, rhs in
1471
+                if lhs.bucketIndex != rhs.bucketIndex {
1472
+                    return lhs.bucketIndex < rhs.bucketIndex
1473
+                }
1474
+                return lhs.timestamp < rhs.timestamp
1475
+            }
1476
+
1477
+        return ChargeSessionSummary(
1478
+            id: id,
1479
+            chargedDeviceID: chargedDeviceID,
1480
+            chargerID: uuidValue(object, key: "chargerID"),
1481
+            meterMACAddress: stringValue(object, key: "meterMACAddress"),
1482
+            meterName: stringValue(object, key: "meterName"),
1483
+            meterModel: stringValue(object, key: "meterModel"),
1484
+            startedAt: startedAt,
1485
+            endedAt: dateValue(object, key: "endedAt"),
1486
+            lastObservedAt: lastObservedAt,
1487
+            status: status,
1488
+            sourceMode: sourceMode,
1489
+            chargingTransportMode: chargingTransportMode,
1490
+            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1491
+            effectiveBatteryEnergyWh: optionalDoubleValue(object, key: "effectiveBatteryEnergyWh"),
1492
+            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1493
+            minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1494
+            maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1495
+            maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
1496
+            maximumObservedVoltageVolts: chargingTransportMode == .wired
1497
+                ? optionalDoubleValue(object, key: "maximumObservedVoltageVolts")
1498
+                : nil,
1499
+            selectedSourceVoltageVolts: optionalDoubleValue(object, key: "selectedSourceVoltageVolts"),
1500
+            completionCurrentAmps: optionalDoubleValue(object, key: "completionCurrentAmps"),
1501
+            stopThresholdAmps: doubleValue(object, key: "stopThresholdAmps"),
1502
+            startBatteryPercent: optionalDoubleValue(object, key: "startBatteryPercent"),
1503
+            endBatteryPercent: optionalDoubleValue(object, key: "endBatteryPercent"),
1504
+            capacityEstimateWh: optionalDoubleValue(object, key: "capacityEstimateWh"),
1505
+            wirelessEfficiencyFactor: optionalDoubleValue(object, key: "wirelessEfficiencyFactor"),
1506
+            usesEstimatedWirelessEfficiency: boolValue(object, key: "usesEstimatedWirelessEfficiency"),
1507
+            shouldWarnAboutLowWirelessEfficiency: boolValue(object, key: "shouldWarnAboutLowWirelessEfficiency"),
1508
+            supportsChargingWhileOff: boolValue(object, key: "supportsChargingWhileOff"),
1509
+            usedOfflineMeterCounters: boolValue(object, key: "usedOfflineMeterCounters"),
1510
+            targetBatteryPercent: optionalDoubleValue(object, key: "targetBatteryPercent"),
1511
+            targetBatteryAlertTriggeredAt: dateValue(object, key: "targetBatteryAlertTriggeredAt"),
1512
+            requiresCompletionConfirmation: boolValue(object, key: "requiresCompletionConfirmation"),
1513
+            completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"),
1514
+            completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"),
1515
+            selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init),
1516
+            checkpoints: checkpointSummaries,
1517
+            aggregatedSamples: sampleSummaries
1518
+        )
1519
+    }
1520
+
1521
+    private func makeCheckpointSummary(from object: NSManagedObject) -> ChargeCheckpointSummary? {
1522
+        guard
1523
+            let id = uuidValue(object, key: "id"),
1524
+            let sessionID = uuidValue(object, key: "sessionID"),
1525
+            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1526
+            let timestamp = dateValue(object, key: "timestamp")
1527
+        else {
1528
+            return nil
1529
+        }
1530
+
1531
+        return ChargeCheckpointSummary(
1532
+            id: id,
1533
+            sessionID: sessionID,
1534
+            chargedDeviceID: chargedDeviceID,
1535
+            timestamp: timestamp,
1536
+            batteryPercent: doubleValue(object, key: "batteryPercent"),
1537
+            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1538
+            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1539
+            currentAmps: doubleValue(object, key: "currentAmps"),
1540
+            voltageVolts: optionalDoubleValue(object, key: "voltageVolts"),
1541
+            label: stringValue(object, key: "label")
1542
+        )
1543
+    }
1544
+
1545
+    private func makeChargeSessionSampleSummary(from object: NSManagedObject) -> ChargeSessionSampleSummary? {
1546
+        guard
1547
+            let sessionID = uuidValue(object, key: "sessionID"),
1548
+            let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"),
1549
+            let timestamp = dateValue(object, key: "timestamp")
1550
+        else {
1551
+            return nil
1552
+        }
1553
+
1554
+        return ChargeSessionSampleSummary(
1555
+            sessionID: sessionID,
1556
+            chargedDeviceID: chargedDeviceID,
1557
+            bucketIndex: Int(optionalInt32Value(object, key: "bucketIndex") ?? 0),
1558
+            timestamp: timestamp,
1559
+            averageCurrentAmps: doubleValue(object, key: "averageCurrentAmps"),
1560
+            averageVoltageVolts: optionalDoubleValue(object, key: "averageVoltageVolts"),
1561
+            averagePowerWatts: doubleValue(object, key: "averagePowerWatts"),
1562
+            measuredEnergyWh: doubleValue(object, key: "measuredEnergyWh"),
1563
+            measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1564
+            sampleCount: Int(optionalInt16Value(object, key: "sampleCount") ?? 0)
1565
+        )
1566
+    }
1567
+
1568
+    private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1569
+        fetchSessionObject(
1570
+            predicate: NSPredicate(
1571
+                format: "meterMACAddress == %@ AND statusRawValue == %@",
1572
+                normalizedMACAddress(meterMACAddress),
1573
+                ChargeSessionStatus.active.rawValue
1574
+            )
1575
+        )
1576
+    }
1577
+
1578
+    private func fetchLatestSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
1579
+        fetchSessionObject(
1580
+            predicate: NSPredicate(
1581
+                format: "meterMACAddress == %@",
1582
+                normalizedMACAddress(meterMACAddress)
1583
+            )
1584
+        )
1585
+    }
1586
+
1587
+    private func fetchSessionObject(predicate: NSPredicate) -> NSManagedObject? {
1588
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1589
+        request.predicate = predicate
1590
+        request.fetchLimit = 1
1591
+        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: false)]
1592
+        return (try? context.fetch(request))?.first
1593
+    }
1594
+
1595
+    private func fetchSessionObject(id: String) -> NSManagedObject? {
1596
+        fetchSessionObject(
1597
+            predicate: NSPredicate(format: "id == %@", id)
1598
+        )
1599
+    }
1600
+
1601
+    private func fetchSessionSampleObject(sessionID: String, bucketIndex: Int32) -> NSManagedObject? {
1602
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
1603
+        request.predicate = NSPredicate(
1604
+            format: "sessionID == %@ AND bucketIndex == %d",
1605
+            sessionID,
1606
+            bucketIndex
1607
+        )
1608
+        request.fetchLimit = 1
1609
+        return (try? context.fetch(request))?.first
1610
+    }
1611
+
1612
+    private func fetchSessionSampleObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1613
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSessionSample)
1614
+        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
1615
+        return (try? context.fetch(request)) ?? []
1616
+    }
1617
+
1618
+    private func fetchCheckpointObjects(forSessionID sessionID: String) -> [NSManagedObject] {
1619
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeCheckpoint)
1620
+        request.predicate = NSPredicate(format: "sessionID == %@", sessionID)
1621
+        request.sortDescriptors = [NSSortDescriptor(key: "timestamp", ascending: true)]
1622
+        return (try? context.fetch(request)) ?? []
1623
+    }
1624
+
1625
+    private func fetchSessions(forChargedDeviceID chargedDeviceID: String) -> [NSManagedObject] {
1626
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1627
+        request.predicate = NSPredicate(format: "chargedDeviceID == %@", chargedDeviceID)
1628
+        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
1629
+        return (try? context.fetch(request)) ?? []
1630
+    }
1631
+
1632
+    private func fetchSessions(forChargerID chargerID: String) -> [NSManagedObject] {
1633
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
1634
+        request.predicate = NSPredicate(format: "chargerID == %@", chargerID)
1635
+        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
1636
+        return (try? context.fetch(request)) ?? []
1637
+    }
1638
+
1639
+    private func relevantSessionObjects(
1640
+        for chargedDeviceID: String,
1641
+        deviceClass: ChargedDeviceClass,
1642
+        sessionsByDeviceID: [String: [NSManagedObject]],
1643
+        sessionsByChargerID: [String: [NSManagedObject]]
1644
+    ) -> [NSManagedObject] {
1645
+        let directSessions = sessionsByDeviceID[chargedDeviceID] ?? []
1646
+        guard deviceClass == .charger else {
1647
+            return directSessions
1648
+        }
1649
+
1650
+        var seenSessionIDs = Set<String>()
1651
+        return (directSessions + (sessionsByChargerID[chargedDeviceID] ?? []))
1652
+            .filter { session in
1653
+                let sessionID = stringValue(session, key: "id") ?? UUID().uuidString
1654
+                return seenSessionIDs.insert(sessionID).inserted
1655
+            }
1656
+            .sorted {
1657
+                let lhsDate = dateValue($0, key: "startedAt") ?? .distantPast
1658
+                let rhsDate = dateValue($1, key: "startedAt") ?? .distantPast
1659
+                return lhsDate < rhsDate
1660
+            }
1661
+    }
1662
+
1663
+    private func resolvedDeviceObject(for meterMACAddress: String) -> NSManagedObject? {
1664
+        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: false)
1665
+    }
1666
+
1667
+    private func resolvedChargerObject(for meterMACAddress: String) -> NSManagedObject? {
1668
+        resolvedAssignedObject(for: meterMACAddress, expectsChargerClass: true)
1669
+    }
1670
+
1671
+    private func resolvedAssignedObject(
1672
+        for meterMACAddress: String,
1673
+        expectsChargerClass: Bool
1674
+    ) -> NSManagedObject? {
1675
+        let normalizedMAC = normalizedMACAddress(meterMACAddress)
1676
+        guard !normalizedMAC.isEmpty else { return nil }
1677
+
1678
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
1679
+        request.predicate = NSPredicate(format: "lastAssociatedMeterMAC == %@", normalizedMAC)
1680
+        request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)]
1681
+        let matches = (try? context.fetch(request)) ?? []
1682
+        return matches.first { object in
1683
+            let isCharger = ChargedDeviceClass(rawValue: stringValue(object, key: "deviceClassRawValue") ?? "") == .charger
1684
+            return isCharger == expectsChargerClass
1685
+        }
1686
+    }
1687
+
1688
+    private func fetchChargedDeviceObject(id: String) -> NSManagedObject? {
1689
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargedDevice)
1690
+        request.predicate = NSPredicate(format: "id == %@", id)
1691
+        request.fetchLimit = 1
1692
+        return (try? context.fetch(request))?.first
1693
+    }
1694
+
1695
+    private func fetchObjects(entityName: String) -> [NSManagedObject] {
1696
+        let request = NSFetchRequest<NSManagedObject>(entityName: entityName)
1697
+        return (try? context.fetch(request)) ?? []
1698
+    }
1699
+
1700
+    private func resolvedStopThreshold(
1701
+        for chargedDevice: NSManagedObject,
1702
+        chargingTransportMode: ChargingTransportMode,
1703
+        fallback: Double
1704
+    ) -> Double {
1705
+        let persistedMinimum: Double?
1706
+        switch chargingTransportMode {
1707
+        case .wired:
1708
+            persistedMinimum = optionalDoubleValue(chargedDevice, key: "wiredChargeCompletionCurrentAmps")
1709
+                ?? optionalDoubleValue(chargedDevice, key: "wiredMinimumCurrentAmps")
1710
+                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
1711
+        case .wireless:
1712
+            persistedMinimum = optionalDoubleValue(chargedDevice, key: "wirelessChargeCompletionCurrentAmps")
1713
+                ?? optionalDoubleValue(chargedDevice, key: "wirelessMinimumCurrentAmps")
1714
+                ?? optionalDoubleValue(chargedDevice, key: "minimumCurrentAmps")
1715
+        }
1716
+        return max(persistedMinimum ?? fallback, 0.01)
1717
+    }
1718
+
1719
+    private func preferredChargingTransportMode(for chargedDevice: NSManagedObject) -> ChargingTransportMode {
1720
+        let supportsWiredCharging = supportsWiredCharging(for: chargedDevice)
1721
+        let supportsWirelessCharging = supportsWirelessCharging(for: chargedDevice)
1722
+        let persistedMode = chargingTransportModeValue(chargedDevice, key: "preferredChargingTransportRawValue") ?? .wired
1723
+        return resolvedPreferredChargingTransportMode(
1724
+            persistedMode,
1725
+            supportsWiredCharging: supportsWiredCharging,
1726
+            supportsWirelessCharging: supportsWirelessCharging
1727
+        )
1728
+    }
1729
+
1730
+    private func supportsWiredCharging(for chargedDevice: NSManagedObject) -> Bool {
1731
+        if chargedDevice.value(forKey: "supportsWiredCharging") == nil {
1732
+            return true
1733
+        }
1734
+        return boolValue(chargedDevice, key: "supportsWiredCharging")
1735
+    }
1736
+
1737
+    private func supportsWirelessCharging(for chargedDevice: NSManagedObject) -> Bool {
1738
+        if chargedDevice.value(forKey: "supportsWirelessCharging") == nil {
1739
+            return false
1740
+        }
1741
+        return boolValue(chargedDevice, key: "supportsWirelessCharging")
1742
+    }
1743
+
1744
+    private func wirelessChargingProfile(for chargedDevice: NSManagedObject) -> WirelessChargingProfile {
1745
+        guard let rawValue = stringValue(chargedDevice, key: "wirelessChargingProfileRawValue"),
1746
+              let profile = WirelessChargingProfile(rawValue: rawValue) else {
1747
+            return .genericQi
1748
+        }
1749
+        return profile
1750
+    }
1751
+
1752
+    private func resolvedPreferredChargingTransportMode(
1753
+        _ preferredChargingTransportMode: ChargingTransportMode,
1754
+        supportsWiredCharging: Bool,
1755
+        supportsWirelessCharging: Bool
1756
+    ) -> ChargingTransportMode {
1757
+        switch preferredChargingTransportMode {
1758
+        case .wired where supportsWiredCharging:
1759
+            return .wired
1760
+        case .wireless where supportsWirelessCharging:
1761
+            return .wireless
1762
+        default:
1763
+            if supportsWiredCharging {
1764
+                return .wired
1765
+            }
1766
+            if supportsWirelessCharging {
1767
+                return .wireless
1768
+            }
1769
+            return .wired
1770
+        }
1771
+    }
1772
+
1773
+    private func derivedMinimumCurrent(
1774
+        from sessions: [NSManagedObject],
1775
+        chargingTransportMode: ChargingTransportMode
1776
+    ) -> Double? {
1777
+        let completionCurrents = sessions.compactMap { session -> Double? in
1778
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1779
+            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
1780
+                return nil
1781
+            }
1782
+            guard let completionCurrent = optionalDoubleValue(session, key: "completionCurrentAmps"), completionCurrent > 0 else {
1783
+                return nil
1784
+            }
1785
+            return completionCurrent
1786
+        }
1787
+
1788
+        let recentCompletionCurrents = Array(completionCurrents.suffix(5))
1789
+        guard !recentCompletionCurrents.isEmpty else { return nil }
1790
+        return recentCompletionCurrents.reduce(0, +) / Double(recentCompletionCurrents.count)
1791
+    }
1792
+
1793
+    private func derivedCapacity(
1794
+        from sessions: [NSManagedObject],
1795
+        chargingTransportMode: ChargingTransportMode,
1796
+        supportsChargingWhileOff: Bool
1797
+    ) -> Double? {
1798
+        let capacityCandidates = sessions.compactMap { session -> Double? in
1799
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1800
+            guard self.chargingTransportMode(for: session) == chargingTransportMode else {
1801
+                return nil
1802
+            }
1803
+            guard let capacityEstimate = optionalDoubleValue(session, key: "capacityEstimateWh"), capacityEstimate > 0 else {
1804
+                return nil
1805
+            }
1806
+            if supportsChargingWhileOff {
1807
+                return capacityEstimate
1808
+            }
1809
+            guard let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), endBatteryPercent < 99.5 else {
1810
+                return nil
1811
+            }
1812
+            return capacityEstimate
1813
+        }
1814
+
1815
+        let recentCapacityCandidates = Array(capacityCandidates.suffix(6))
1816
+        guard !recentCapacityCandidates.isEmpty else { return nil }
1817
+        return recentCapacityCandidates.reduce(0, +) / Double(recentCapacityCandidates.count)
1818
+    }
1819
+
1820
+    private func derivedWirelessEfficiency(
1821
+        from sessions: [NSManagedObject],
1822
+        chargingProfile: WirelessChargingProfile
1823
+    ) -> Double? {
1824
+        guard chargingProfile == .magsafe else {
1825
+            return nil
1826
+        }
1827
+
1828
+        let candidates = sessions.compactMap { session -> Double? in
1829
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1830
+            guard chargingTransportMode(for: session) == .wireless else { return nil }
1831
+            guard boolValue(session, key: "usesEstimatedWirelessEfficiency") == false else { return nil }
1832
+            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
1833
+                return nil
1834
+            }
1835
+            return factor
1836
+        }
1837
+
1838
+        let recentCandidates = Array(candidates.suffix(6))
1839
+        guard !recentCandidates.isEmpty else { return nil }
1840
+        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
1841
+    }
1842
+
1843
+    private func derivedObservedVoltageSelections(from sessions: [NSManagedObject]) -> [Double] {
1844
+        let candidates = sessions.compactMap { session -> Double? in
1845
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1846
+            guard let sourceVoltage = optionalDoubleValue(session, key: "selectedSourceVoltageVolts"), sourceVoltage > 0 else {
1847
+                return nil
1848
+            }
1849
+            return (sourceVoltage * 10).rounded() / 10
1850
+        }
1851
+
1852
+        let counts = Dictionary(grouping: candidates, by: { $0 }).mapValues(\.count)
1853
+        return counts.keys.sorted()
1854
+    }
1855
+
1856
+    private func derivedIdleCurrent(from sessions: [NSManagedObject]) -> Double? {
1857
+        let candidates = sessions.compactMap { session -> Double? in
1858
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1859
+            guard let minimumObservedCurrent = optionalDoubleValue(session, key: "minimumObservedCurrentAmps"), minimumObservedCurrent > 0 else {
1860
+                return nil
1861
+            }
1862
+            return minimumObservedCurrent
1863
+        }
1864
+
1865
+        let recentCandidates = Array(candidates.suffix(6))
1866
+        guard !recentCandidates.isEmpty else { return nil }
1867
+        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
1868
+    }
1869
+
1870
+    private func derivedChargerEfficiency(from sessions: [NSManagedObject]) -> Double? {
1871
+        let candidates = sessions.compactMap { session -> Double? in
1872
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1873
+            guard let factor = optionalDoubleValue(session, key: "wirelessEfficiencyFactor"), factor > 0 else {
1874
+                return nil
1875
+            }
1876
+            return factor
1877
+        }
1878
+
1879
+        let recentCandidates = Array(candidates.suffix(6))
1880
+        guard !recentCandidates.isEmpty else { return nil }
1881
+        return recentCandidates.reduce(0, +) / Double(recentCandidates.count)
1882
+    }
1883
+
1884
+    private func derivedMaximumPower(from sessions: [NSManagedObject]) -> Double? {
1885
+        sessions.compactMap { session -> Double? in
1886
+            guard statusValue(session, key: "statusRawValue") == .completed else { return nil }
1887
+            guard let maximumObservedPower = optionalDoubleValue(session, key: "maximumObservedPowerWatts"), maximumObservedPower > 0 else {
1888
+                return nil
1889
+            }
1890
+            return maximumObservedPower
1891
+        }
1892
+        .max()
1893
+    }
1894
+
1895
+    private func chargingTransportMode(for session: NSManagedObject) -> ChargingTransportMode {
1896
+        if let persistedChargingTransportMode = chargingTransportModeValue(session, key: "chargingTransportRawValue") {
1897
+            return persistedChargingTransportMode
1898
+        }
1899
+
1900
+        if let chargedDeviceID = stringValue(session, key: "chargedDeviceID"),
1901
+           let chargedDevice = fetchChargedDeviceObject(id: chargedDeviceID) {
1902
+            return preferredChargingTransportMode(for: chargedDevice)
1903
+        }
1904
+
1905
+        return .wired
1906
+    }
1907
+
1908
+    private func saveReason(for session: NSManagedObject, observedAt: Date) -> ObservationSaveReason {
1909
+        if session.isInserted {
1910
+            return .created
1911
+        }
1912
+
1913
+        let committedValues = session.committedValues(
1914
+            forKeys: [
1915
+                "statusRawValue",
1916
+                "updatedAt",
1917
+                "targetBatteryAlertTriggeredAt",
1918
+                "requiresCompletionConfirmation"
1919
+            ]
1920
+        )
1921
+        let committedStatus = (committedValues["statusRawValue"] as? String).flatMap(ChargeSessionStatus.init(rawValue:))
1922
+        let currentStatus = statusValue(session, key: "statusRawValue")
1923
+        let committedTargetAlertTriggeredAt = committedValues["targetBatteryAlertTriggeredAt"] as? Date
1924
+        let currentTargetAlertTriggeredAt = dateValue(session, key: "targetBatteryAlertTriggeredAt")
1925
+        let committedRequiresCompletionConfirmation = (committedValues["requiresCompletionConfirmation"] as? NSNumber)?.boolValue
1926
+            ?? (committedValues["requiresCompletionConfirmation"] as? Bool)
1927
+            ?? false
1928
+        let currentRequiresCompletionConfirmation = boolValue(session, key: "requiresCompletionConfirmation")
1929
+
1930
+        if currentStatus == .completed, committedStatus != .completed {
1931
+            return .completed
1932
+        }
1933
+
1934
+        if committedTargetAlertTriggeredAt != currentTargetAlertTriggeredAt
1935
+            || committedRequiresCompletionConfirmation != currentRequiresCompletionConfirmation {
1936
+            return .event
1937
+        }
1938
+
1939
+        let lastPersistedAt = (committedValues["updatedAt"] as? Date)
1940
+            ?? dateValue(session, key: "createdAt")
1941
+            ?? observedAt
1942
+
1943
+        if observedAt.timeIntervalSince(lastPersistedAt) >= activeSessionSaveInterval {
1944
+            return .periodic
1945
+        }
1946
+
1947
+        return .none
1948
+    }
1949
+
1950
+    private func generateQRIdentifier() -> String {
1951
+        "device:\(UUID().uuidString)"
1952
+    }
1953
+
1954
+    @discardableResult
1955
+    private func saveContext() -> Bool {
1956
+        guard context.hasChanges else { return true }
1957
+        do {
1958
+            try context.save()
1959
+            return true
1960
+        } catch {
1961
+            track("Failed saving charge insights context: \(error)")
1962
+            context.rollback()
1963
+            return false
1964
+        }
1965
+    }
1966
+
1967
+    private func normalizedText(_ text: String) -> String {
1968
+        text.trimmingCharacters(in: .whitespacesAndNewlines)
1969
+    }
1970
+
1971
+    private func normalizedOptionalText(_ text: String?) -> String? {
1972
+        guard let text else { return nil }
1973
+        let normalized = normalizedText(text)
1974
+        return normalized.isEmpty ? nil : normalized
1975
+    }
1976
+
1977
+    private func normalizedMACAddress(_ macAddress: String) -> String {
1978
+        normalizedText(macAddress).uppercased()
1979
+    }
1980
+
1981
+    private func stringValue(_ object: NSManagedObject, key: String) -> String? {
1982
+        guard let value = object.value(forKey: key) as? String else { return nil }
1983
+        let normalized = normalizedOptionalText(value)
1984
+        return normalized
1985
+    }
1986
+
1987
+    private func dateValue(_ object: NSManagedObject, key: String) -> Date? {
1988
+        object.value(forKey: key) as? Date
1989
+    }
1990
+
1991
+    private func doubleValue(_ object: NSManagedObject, key: String) -> Double {
1992
+        if let value = object.value(forKey: key) as? Double {
1993
+            return value
1994
+        }
1995
+        if let value = object.value(forKey: key) as? NSNumber {
1996
+            return value.doubleValue
1997
+        }
1998
+        return 0
1999
+    }
2000
+
2001
+    private func optionalDoubleValue(_ object: NSManagedObject, key: String) -> Double? {
2002
+        let rawValue = object.value(forKey: key)
2003
+        if rawValue == nil {
2004
+            return nil
2005
+        }
2006
+        return doubleValue(object, key: key)
2007
+    }
2008
+
2009
+    private func optionalInt16Value(_ object: NSManagedObject, key: String) -> Int16? {
2010
+        if let value = object.value(forKey: key) as? Int16 {
2011
+            return value
2012
+        }
2013
+        if let value = object.value(forKey: key) as? NSNumber {
2014
+            return value.int16Value
2015
+        }
2016
+        return nil
2017
+    }
2018
+
2019
+    private func optionalInt32Value(_ object: NSManagedObject, key: String) -> Int32? {
2020
+        if let value = object.value(forKey: key) as? Int32 {
2021
+            return value
2022
+        }
2023
+        if let value = object.value(forKey: key) as? NSNumber {
2024
+            return value.int32Value
2025
+        }
2026
+        return nil
2027
+    }
2028
+
2029
+    private func boolValue(_ object: NSManagedObject, key: String) -> Bool {
2030
+        if let value = object.value(forKey: key) as? Bool {
2031
+            return value
2032
+        }
2033
+        if let value = object.value(forKey: key) as? NSNumber {
2034
+            return value.boolValue
2035
+        }
2036
+        return false
2037
+    }
2038
+
2039
+    private func uuidValue(_ object: NSManagedObject, key: String) -> UUID? {
2040
+        guard let value = stringValue(object, key: key) else { return nil }
2041
+        return UUID(uuidString: value)
2042
+    }
2043
+
2044
+    private func statusValue(_ object: NSManagedObject, key: String) -> ChargeSessionStatus? {
2045
+        guard let value = stringValue(object, key: key) else { return nil }
2046
+        return ChargeSessionStatus(rawValue: value)
2047
+    }
2048
+
2049
+    private func chargingTransportModeValue(_ object: NSManagedObject, key: String) -> ChargingTransportMode? {
2050
+        guard let value = stringValue(object, key: key) else { return nil }
2051
+        return ChargingTransportMode(rawValue: value)
2052
+    }
2053
+
2054
+    private func decodedObservedVoltageSelections(from object: NSManagedObject) -> [Double] {
2055
+        guard let rawValue = stringValue(object, key: "chargerObservedVoltageSelectionsRawValue") else {
2056
+            return []
2057
+        }
2058
+        return rawValue
2059
+            .split(separator: ",")
2060
+            .compactMap { Double($0) }
2061
+            .sorted()
2062
+    }
2063
+
2064
+    private func encodedObservedVoltageSelections(_ voltages: [Double]) -> String? {
2065
+        let uniqueVoltages = Array(Set(voltages)).sorted()
2066
+        guard !uniqueVoltages.isEmpty else {
2067
+            return nil
2068
+        }
2069
+        return uniqueVoltages
2070
+            .map { String(format: "%.1f", $0) }
2071
+            .joined(separator: ",")
2072
+    }
2073
+
2074
+    private func runningAverage(currentAverage: Double, currentCount: Int, newValue: Double) -> Double {
2075
+        guard currentCount > 0 else {
2076
+            return newValue
2077
+        }
2078
+        let total = (currentAverage * Double(currentCount)) + newValue
2079
+        return total / Double(currentCount + 1)
2080
+    }
2081
+}
2082
+
2083
+private enum ObservationSaveReason {
2084
+    case none
2085
+    case created
2086
+    case periodic
2087
+    case completed
2088
+    case event
2089
+}
+194 -12
USB Meter/Model/Measurements.swift
@@ -10,6 +10,46 @@ import Foundation
10 10
 import CoreGraphics
11 11
 
12 12
 class Measurements : ObservableObject {
13
+    struct EnergyProjectionSnapshot {
14
+        let accumulatedEnergy: Double
15
+        let observedDuration: TimeInterval
16
+        let sampleCount: Int
17
+        let averagePower: Double?
18
+
19
+        var projectedDailyEnergy: Double? {
20
+            projectedEnergy(forHours: 24)
21
+        }
22
+
23
+        var projectedMonthlyEnergy: Double? {
24
+            projectedEnergy(forHours: 24 * 30)
25
+        }
26
+
27
+        var projectedYearlyEnergy: Double? {
28
+            projectedEnergy(forHours: 24 * 365)
29
+        }
30
+
31
+        private func projectedEnergy(forHours hours: Double) -> Double? {
32
+            guard let averagePower, averagePower.isFinite else { return nil }
33
+            return averagePower * hours
34
+        }
35
+    }
36
+
37
+    struct EnergyProjectionVariant: Identifiable {
38
+        let id: String
39
+        let title: String
40
+        let observedDuration: TimeInterval
41
+        let accumulatedEnergy: Double
42
+        let sampleCount: Int
43
+        let averagePower: Double
44
+
45
+        var projectedMonthlyEnergy: Double {
46
+            averagePower * 24 * 30
47
+        }
48
+
49
+        var projectedYearlyEnergy: Double {
50
+            averagePower * 24 * 365
51
+        }
52
+    }
13 53
 
14 54
     class Measurement : ObservableObject {
15 55
         struct Point : Identifiable , Hashable {
@@ -150,6 +190,29 @@ class Measurements : ObservableObject {
150 190
             self.objectWillChange.send()
151 191
         }
152 192
 
193
+        func alignCounterToStartAtZero() {
194
+            guard let firstSampleIndex = points.firstIndex(where: \.isSample) else {
195
+                if !points.isEmpty {
196
+                    resetSeries()
197
+                }
198
+                return
199
+            }
200
+
201
+            let baselineValue = points[firstSampleIndex].value
202
+            points = points[firstSampleIndex...]
203
+                .enumerated()
204
+                .map { index, point in
205
+                    Point(
206
+                        id: index,
207
+                        timestamp: point.timestamp,
208
+                        value: point.value - baselineValue,
209
+                        kind: point.kind
210
+                    )
211
+                }
212
+            rebuildContext()
213
+            self.objectWillChange.send()
214
+        }
215
+
153 216
         private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
154 217
             var lowerBound = 0
155 218
             var upperBound = points.count
@@ -228,6 +291,13 @@ class Measurements : ObservableObject {
228 291
         self.objectWillChange.send()
229 292
     }
230 293
 
294
+    private func realignEnergyBufferStart() {
295
+        energy.alignCounterToStartAtZero()
296
+        lastEnergyCounterValue = nil
297
+        lastEnergyGroupID = nil
298
+        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
299
+    }
300
+
231 301
     func resetSeries() {
232 302
         power.resetSeries()
233 303
         voltage.resetSeries()
@@ -253,9 +323,7 @@ class Measurements : ObservableObject {
253 323
         temperature.removeValue(index: idx)
254 324
         energy.removeValue(index: idx)
255 325
         rssi.removeValue(index: idx)
256
-        lastEnergyCounterValue = nil
257
-        lastEnergyGroupID = nil
258
-        accumulatedEnergyValue = 0
326
+        realignEnergyBufferStart()
259 327
         self.objectWillChange.send()
260 328
     }
261 329
 
@@ -267,9 +335,7 @@ class Measurements : ObservableObject {
267 335
         temperature.trim(before: cutoff)
268 336
         energy.trim(before: cutoff)
269 337
         rssi.trim(before: cutoff)
270
-        lastEnergyCounterValue = nil
271
-        lastEnergyGroupID = nil
272
-        accumulatedEnergyValue = 0
338
+        realignEnergyBufferStart()
273 339
         self.objectWillChange.send()
274 340
     }
275 341
 
@@ -281,9 +347,7 @@ class Measurements : ObservableObject {
281 347
         temperature.filterSamples { range.contains($0) }
282 348
         energy.filterSamples { range.contains($0) }
283 349
         rssi.filterSamples { range.contains($0) }
284
-        lastEnergyCounterValue = nil
285
-        lastEnergyGroupID = nil
286
-        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
350
+        realignEnergyBufferStart()
287 351
         self.objectWillChange.send()
288 352
     }
289 353
 
@@ -295,9 +359,7 @@ class Measurements : ObservableObject {
295 359
         temperature.filterSamples { !range.contains($0) }
296 360
         energy.filterSamples { !range.contains($0) }
297 361
         rssi.filterSamples { !range.contains($0) }
298
-        lastEnergyCounterValue = nil
299
-        lastEnergyGroupID = nil
300
-        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
362
+        realignEnergyBufferStart()
301 363
         self.objectWillChange.send()
302 364
     }
303 365
 
@@ -382,4 +444,124 @@ class Measurements : ObservableObject {
382 444
 
383 445
         return sum / Double(points.count)
384 446
     }
447
+
448
+    func energyProjectionSnapshot(flushPendingValues shouldFlushPendingValues: Bool = true) -> EnergyProjectionSnapshot? {
449
+        if shouldFlushPendingValues {
450
+            flushPendingValues()
451
+        }
452
+
453
+        let samplePoints = energy.samplePoints
454
+        guard !samplePoints.isEmpty else { return nil }
455
+
456
+        let accumulatedEnergy = samplePoints.last?.value ?? 0
457
+        var observedDuration: TimeInterval = 0
458
+        var previousSample: Measurement.Point?
459
+
460
+        for point in energy.points {
461
+            if point.isDiscontinuity {
462
+                previousSample = nil
463
+                continue
464
+            }
465
+
466
+            if let previousSample {
467
+                observedDuration += max(0, point.timestamp.timeIntervalSince(previousSample.timestamp))
468
+            }
469
+
470
+            previousSample = point
471
+        }
472
+
473
+        let averagePower: Double?
474
+        if observedDuration > 0, accumulatedEnergy.isFinite {
475
+            averagePower = accumulatedEnergy / (observedDuration / 3600)
476
+        } else {
477
+            averagePower = nil
478
+        }
479
+
480
+        return EnergyProjectionSnapshot(
481
+            accumulatedEnergy: accumulatedEnergy,
482
+            observedDuration: observedDuration,
483
+            sampleCount: samplePoints.count,
484
+            averagePower: averagePower
485
+        )
486
+    }
487
+
488
+    func energyProjectionVariants(flushPendingValues shouldFlushPendingValues: Bool = true) -> [EnergyProjectionVariant] {
489
+        if shouldFlushPendingValues {
490
+            flushPendingValues()
491
+        }
492
+
493
+        let contiguousSamples = latestContiguousEnergySamples()
494
+        guard contiguousSamples.count >= 2 else { return [] }
495
+
496
+        let latestTimestamp = contiguousSamples.last?.timestamp ?? Date()
497
+        let windowCandidates: [(duration: TimeInterval, title: String, id: String)] = [
498
+            (60, "Last 1 Minute", "last-1m"),
499
+            (5 * 60, "Last 5 Minutes", "last-5m"),
500
+            (15 * 60, "Last 15 Minutes", "last-15m"),
501
+            (60 * 60, "Last 1 Hour", "last-1h"),
502
+            (6 * 60 * 60, "Last 6 Hours", "last-6h")
503
+        ]
504
+
505
+        var variants: [EnergyProjectionVariant] = []
506
+
507
+        for candidate in windowCandidates {
508
+            let cutoff = latestTimestamp.addingTimeInterval(-candidate.duration)
509
+            guard
510
+                let startIndex = contiguousSamples.lastIndex(where: { $0.timestamp <= cutoff }),
511
+                startIndex < contiguousSamples.count - 1
512
+            else {
513
+                continue
514
+            }
515
+
516
+            let relevantSamples = Array(contiguousSamples[startIndex...])
517
+            if let variant = projectionVariant(
518
+                id: candidate.id,
519
+                title: candidate.title,
520
+                samples: relevantSamples
521
+            ) {
522
+                variants.append(variant)
523
+            }
524
+        }
525
+
526
+        if let fullBufferVariant = projectionVariant(
527
+            id: "full-buffer",
528
+            title: "Whole Buffer",
529
+            samples: contiguousSamples
530
+        ) {
531
+            variants.append(fullBufferVariant)
532
+        }
533
+
534
+        return variants
535
+    }
536
+
537
+    private func latestContiguousEnergySamples() -> [Measurement.Point] {
538
+        let latestSegment = energy.points.split(whereSeparator: \.isDiscontinuity).last ?? []
539
+        return latestSegment.filter(\.isSample)
540
+    }
541
+
542
+    private func projectionVariant(
543
+        id: String,
544
+        title: String,
545
+        samples: [Measurement.Point]
546
+    ) -> EnergyProjectionVariant? {
547
+        guard let firstSample = samples.first, let lastSample = samples.last else { return nil }
548
+
549
+        let observedDuration = lastSample.timestamp.timeIntervalSince(firstSample.timestamp)
550
+        guard observedDuration > 0 else { return nil }
551
+
552
+        let accumulatedEnergy = lastSample.value - firstSample.value
553
+        guard accumulatedEnergy >= 0, accumulatedEnergy.isFinite else { return nil }
554
+
555
+        let averagePower = accumulatedEnergy / (observedDuration / 3600)
556
+        guard averagePower.isFinite else { return nil }
557
+
558
+        return EnergyProjectionVariant(
559
+            id: id,
560
+            title: title,
561
+            observedDuration: observedDuration,
562
+            accumulatedEnergy: accumulatedEnergy,
563
+            sampleCount: samples.count,
564
+            averagePower: averagePower
565
+        )
566
+    }
385 567
 }
+74 -1
USB Meter/Model/Meter.swift
@@ -22,7 +22,7 @@ import SwiftUI
22 22
  [UM Series](https://sigrok.org/wiki/RDTech_UM_series)
23 23
  [TC66C](https://sigrok.org/wiki/RDTech_TC66C)
24 24
  */
25
-enum Model: CaseIterable {
25
+enum Model: CaseIterable, Hashable {
26 26
     case UM25C
27 27
     case UM34C
28 28
     case TC66C
@@ -528,6 +528,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
528 528
     private var pendingVolatileMemoryResetIgnoreCount = 0
529 529
     private var pendingVolatileMemoryResetDeadline: Date?
530 530
     private var liveDataChanged = false
531
+    private var restoredChargeSessionID: UUID?
531 532
         
532 533
     @discardableResult
533 534
     private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, to value: T) -> Bool {
@@ -606,6 +607,38 @@ class Meter : NSObject, ObservableObject, Identifiable {
606 607
         return (groupID, record.wh)
607 608
     }
608 609
 
610
+    private func currentChargeSample() -> (groupID: UInt8, value: Double)? {
611
+        guard showsDataGroupEnergy else { return nil }
612
+
613
+        if model == .TC66C && !hasObservedActiveDataGroup {
614
+            return nil
615
+        }
616
+
617
+        let groupID = selectedDataGroup
618
+        guard let record = dataGroupRecords[Int(groupID)] else { return nil }
619
+        return (groupID, record.ah)
620
+    }
621
+
622
+    func chargingMonitorSnapshot(at observedAt: Date) -> ChargingMonitorSnapshot? {
623
+        ChargingMonitorSnapshot(
624
+            meterMACAddress: btSerial.macAddress.description,
625
+            meterName: name,
626
+            meterModel: deviceModelSummary,
627
+            observedAt: observedAt,
628
+            voltageVolts: voltage,
629
+            currentAmps: current,
630
+            powerWatts: power,
631
+            selectedDataGroup: currentEnergySample()?.groupID ?? currentChargeSample()?.groupID,
632
+            meterChargeCounterAh: currentChargeSample()?.value,
633
+            meterEnergyCounterWh: currentEnergySample()?.value,
634
+            fallbackStopThresholdAmps: chargeRecordStopThreshold
635
+        )
636
+    }
637
+
638
+    var chargingMonitorSnapshot: ChargingMonitorSnapshot? {
639
+        chargingMonitorSnapshot(at: Date())
640
+    }
641
+
609 642
     private func cancelPendingDataDumpRequest(reason: String) {
610 643
         guard let pendingDataDumpWorkItem else { return }
611 644
         track("\(name) - Cancel scheduled data request (\(reason))")
@@ -770,6 +803,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
770 803
             temperature: displayedTemperatureValue,
771 804
             rssi: Double(btSerial.averageRSSI)
772 805
         )
806
+        appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp)
773 807
 //        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
774 808
 //            //track("\(name) - Scheduled new request.")
775 809
 //        }
@@ -922,6 +956,45 @@ class Meter : NSObject, ObservableObject, Identifiable {
922 956
         resetChargeRecord()
923 957
         measurements.trim(before: cutoff)
924 958
     }
959
+
960
+    func restoreChargeRecordIfNeeded(from activeSession: ChargeSessionSummary) {
961
+        guard chargeRecordState == .waitingForStart else { return }
962
+        guard chargeRecordStartTimestamp == nil else { return }
963
+        guard chargeRecordAH == 0, chargeRecordWH == 0, chargeRecordDuration == 0 else { return }
964
+
965
+        chargeRecordState = .active
966
+        chargeRecordAH = activeSession.measuredChargeAh
967
+        chargeRecordWH = activeSession.measuredEnergyWh
968
+        chargeRecordDuration = max(activeSession.lastObservedAt.timeIntervalSince(activeSession.startedAt), 0)
969
+        chargeRecordStopThreshold = activeSession.stopThresholdAmps
970
+        chargeRecordStartTimestamp = activeSession.startedAt
971
+        chargeRecordEndTimestamp = activeSession.lastObservedAt
972
+        chargeRecordLastTimestamp = nil
973
+        chargeRecordLastCurrent = 0
974
+        chargeRecordLastPower = 0
975
+        if let selectedDataGroup = activeSession.selectedDataGroup {
976
+            self.selectedDataGroup = selectedDataGroup
977
+        }
978
+        objectWillChange.send()
979
+    }
980
+
981
+    func restoreChargeMonitoringIfNeeded(from activeSession: ChargeSessionSummary) {
982
+        restoreChargeRecordIfNeeded(from: activeSession)
983
+
984
+        guard restoredChargeSessionID != activeSession.id else {
985
+            return
986
+        }
987
+
988
+        restoredChargeSessionID = activeSession.id
989
+        enableAutoConnect = true
990
+
991
+        guard operationalState < .peripheralConnectionPending else {
992
+            return
993
+        }
994
+
995
+        track("\(name) - Restoring active charge session and reconnecting to meter")
996
+        btSerial.connect()
997
+    }
925 998
         
926 999
     func nextScreen() {
927 1000
         switch model {
+66 -0
USB Meter/Model/MeterNameStore.swift
@@ -196,6 +196,36 @@ final class MeterNameStore {
196 196
         }
197 197
     }
198 198
 
199
+    @discardableResult
200
+    func remove(macAddress: String) -> Bool {
201
+        let normalizedMAC = normalizedMACAddress(macAddress)
202
+        guard !normalizedMAC.isEmpty else {
203
+            track("MeterNameStore ignored remove with invalid MAC '\(macAddress)'")
204
+            return false
205
+        }
206
+
207
+        var didChange = false
208
+
209
+        var knownMeters = meters()
210
+        if knownMeters.remove(normalizedMAC) != nil {
211
+            defaults.set(Array(knownMeters).sorted(), forKey: Keys.meters)
212
+            didChange = true
213
+        }
214
+
215
+        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames) || didChange
216
+        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits) || didChange
217
+        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localModelNames, cloudKey: nil) || didChange
218
+        didChange = removeDictionaryEntry(for: normalizedMAC, localKey: Keys.localAdvertisedNames, cloudKey: nil) || didChange
219
+        didChange = removeDateEntry(for: normalizedMAC, key: Keys.localLastSeen) || didChange
220
+        didChange = removeDateEntry(for: normalizedMAC, key: Keys.localLastConnected) || didChange
221
+
222
+        if didChange {
223
+            notifyChange()
224
+        }
225
+
226
+        return didChange
227
+    }
228
+
199 229
     func allRecords() -> [Record] {
200 230
         let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
201 231
         let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
@@ -333,6 +363,42 @@ final class MeterNameStore {
333 363
         notifyChange()
334 364
     }
335 365
 
366
+    @discardableResult
367
+    private func removeDictionaryEntry(
368
+        for macAddress: String,
369
+        localKey: String,
370
+        cloudKey: String?
371
+    ) -> Bool {
372
+        var didChange = false
373
+
374
+        var localValues = dictionary(for: localKey, store: defaults)
375
+        if localValues.removeValue(forKey: macAddress) != nil {
376
+            defaults.set(localValues, forKey: localKey)
377
+            didChange = true
378
+        }
379
+
380
+        if let cloudKey, isICloudDriveAvailable {
381
+            var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
382
+            if cloudValues.removeValue(forKey: macAddress) != nil {
383
+                ubiquitousStore.set(cloudValues, forKey: cloudKey)
384
+                ubiquitousStore.synchronize()
385
+                didChange = true
386
+            }
387
+        }
388
+
389
+        return didChange
390
+    }
391
+
392
+    @discardableResult
393
+    private func removeDateEntry(for macAddress: String, key: String) -> Bool {
394
+        var values = (defaults.object(forKey: key) as? [String: TimeInterval]) ?? [:]
395
+        guard values.removeValue(forKey: macAddress) != nil else {
396
+            return false
397
+        }
398
+        defaults.set(values, forKey: key)
399
+        return true
400
+    }
401
+
336 402
     private var isICloudDriveAvailable: Bool {
337 403
         FileManager.default.ubiquityIdentityToken != nil
338 404
     }
+2 -1
USB Meter/SceneDelegate.swift
@@ -47,6 +47,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
47 47
     func sceneWillResignActive(_ scene: UIScene) {
48 48
         // Called when the scene will move from an active state to an inactive state.
49 49
         // This may occur due to temporary interruptions (ex. an incoming phone call).
50
+        _ = appData.flushChargeInsights()
50 51
     }
51 52
     
52 53
     func sceneWillEnterForeground(_ scene: UIScene) {
@@ -58,7 +59,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
58 59
         // Called as the scene transitions from the foreground to the background.
59 60
         // Use this method to save data, release shared resources, and store enough scene-specific state information
60 61
         // to restore the scene back to its current state.
61
-        
62
+        _ = appData.flushChargeInsights()
62 63
     }
63 64
     
64 65
     
+12 -0
USB Meter/USB Meter.entitlements
@@ -2,6 +2,18 @@
2 2
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3 3
 <plist version="1.0">
4 4
 <dict>
5
+	<key>com.apple.developer.icloud-container-identifiers</key>
6
+	<array>
7
+		<string>iCloud.ro.xdev.USB-Meter</string>
8
+	</array>
9
+	<key>com.apple.developer.icloud-services</key>
10
+	<array>
11
+		<string>CloudKit</string>
12
+	</array>
13
+	<key>com.apple.developer.ubiquity-container-identifiers</key>
14
+	<array>
15
+		<string>iCloud.ro.xdev.USB-Meter</string>
16
+	</array>
5 17
 	<key>com.apple.developer.ubiquity-kvstore-identifier</key>
6 18
 	<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
7 19
 </dict>
+67 -0
USB Meter/Views/ChargedDevices/BatteryCheckpointEditorSheetView.swift
@@ -0,0 +1,67 @@
1
+//
2
+//  BatteryCheckpointEditorSheetView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct BatteryCheckpointEditorSheetView: View {
11
+    @EnvironmentObject private var appData: AppData
12
+    @EnvironmentObject private var meter: Meter
13
+    @Environment(\.dismiss) private var dismiss
14
+
15
+    @State private var batteryPercent = ""
16
+    @State private var label = ""
17
+
18
+    var body: some View {
19
+        NavigationView {
20
+            Form {
21
+                Section(header: Text("Checkpoint")) {
22
+                    TextField("Battery %", text: $batteryPercent)
23
+                        .keyboardType(.decimalPad)
24
+                    TextField("Label (optional)", text: $label)
25
+                }
26
+
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)
31
+                }
32
+            }
33
+            .navigationTitle("Battery Checkpoint")
34
+            .navigationBarTitleDisplayMode(.inline)
35
+            .toolbar {
36
+                ToolbarItem(placement: .cancellationAction) {
37
+                    Button("Cancel") {
38
+                        dismiss()
39
+                    }
40
+                }
41
+                ToolbarItem(placement: .confirmationAction) {
42
+                    Button("Save") {
43
+                        guard let percent = Double(batteryPercent) else {
44
+                            return
45
+                        }
46
+
47
+                        _ = appData.ensureChargeSession(for: meter)
48
+                        let didSave = appData.addBatteryCheckpoint(
49
+                            percent: percent,
50
+                            label: label,
51
+                            for: meter
52
+                        )
53
+                        if didSave {
54
+                            dismiss()
55
+                        }
56
+                    }
57
+                    .disabled(
58
+                        (Double(batteryPercent) ?? -1) < 0
59
+                            || (Double(batteryPercent) ?? 101) > 100
60
+                            || appData.currentChargedDeviceSummary(for: meter.btSerial.macAddress.description) == nil
61
+                    )
62
+                }
63
+            }
64
+        }
65
+        .navigationViewStyle(StackNavigationViewStyle())
66
+    }
67
+}
+912 -0
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -0,0 +1,912 @@
1
+//
2
+//  ChargedDeviceDetailView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct ChargedDeviceDetailView: View {
11
+    @EnvironmentObject private var appData: AppData
12
+    @Environment(\.dismiss) private var dismiss
13
+    @State private var editorVisibility = false
14
+    @State private var checkpointEditorVisibility = false
15
+    @State private var targetNotificationEditorVisibility = false
16
+    @State private var pendingSessionDeletion: ChargeSessionSummary?
17
+    @State private var deleteConfirmationVisibility = false
18
+
19
+    let chargedDeviceID: UUID
20
+
21
+    var body: some View {
22
+        Group {
23
+            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
24
+                ScrollView {
25
+                    VStack(spacing: 18) {
26
+                        headerCard(chargedDevice)
27
+                        insightsCard(chargedDevice)
28
+
29
+                        if let activeSession = chargedDevice.activeSession {
30
+                            activeSessionCard(activeSession, chargedDevice: chargedDevice)
31
+                        }
32
+
33
+                        if let curveSession = preferredStoredCurveSession(for: chargedDevice) {
34
+                            storedCurveCard(curveSession)
35
+                        }
36
+
37
+                        if !chargedDevice.capacityHistory.isEmpty {
38
+                            capacityEvolutionCard(chargedDevice)
39
+                        }
40
+
41
+                        if !chargedDevice.typicalCurve.isEmpty {
42
+                            typicalCurveCard(chargedDevice)
43
+                        }
44
+
45
+                        if !chargedDevice.sessions.isEmpty {
46
+                            sessionsCard(chargedDevice)
47
+                        }
48
+                    }
49
+                    .padding()
50
+                }
51
+                .background(
52
+                    LinearGradient(
53
+                        colors: [tint(for: chargedDevice).opacity(0.18), Color.clear],
54
+                        startPoint: .topLeading,
55
+                        endPoint: .bottomTrailing
56
+                    )
57
+                    .ignoresSafeArea()
58
+                )
59
+                .navigationTitle(chargedDevice.name)
60
+                .toolbar {
61
+                    ToolbarItemGroup(placement: .primaryAction) {
62
+                        Button("Edit") {
63
+                            editorVisibility = true
64
+                        }
65
+                        Button(role: .destructive) {
66
+                            deleteConfirmationVisibility = true
67
+                        } label: {
68
+                            Image(systemName: "trash")
69
+                        }
70
+                    }
71
+                }
72
+            } else {
73
+                Text("This device is no longer available.")
74
+                    .foregroundColor(.secondary)
75
+                    .navigationTitle("Device")
76
+            }
77
+        }
78
+        .sheet(isPresented: $editorVisibility) {
79
+            if let chargedDevice = appData.chargedDeviceSummary(id: chargedDeviceID) {
80
+                ChargedDeviceEditorSheetView(
81
+                    meterMACAddress: nil,
82
+                    chargedDevice: chargedDevice
83
+                )
84
+                .environmentObject(appData)
85
+            }
86
+        }
87
+        .sheet(isPresented: $checkpointEditorVisibility) {
88
+            if let sessionID = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession?.id {
89
+                ChargedDeviceCheckpointEditorSheetView(sessionID: sessionID)
90
+                    .environmentObject(appData)
91
+            }
92
+        }
93
+        .sheet(isPresented: $targetNotificationEditorVisibility) {
94
+            if let activeSession = appData.chargedDeviceSummary(id: chargedDeviceID)?.activeSession {
95
+                ChargedDeviceTargetNotificationEditorSheetView(
96
+                    sessionID: activeSession.id,
97
+                    initialTargetPercent: activeSession.targetBatteryPercent
98
+                )
99
+                .environmentObject(appData)
100
+            }
101
+        }
102
+        .alert(item: $pendingSessionDeletion) { session in
103
+            Alert(
104
+                title: Text("Delete Session?"),
105
+                message: Text("Deleting this charging session also recalculates capacity and every derived metric that used it."),
106
+                primaryButton: .destructive(Text("Delete")) {
107
+                    _ = appData.deleteChargeSession(sessionID: session.id)
108
+                },
109
+                secondaryButton: .cancel()
110
+            )
111
+        }
112
+        .confirmationDialog("Delete \(deletionTitle)?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
113
+            Button("Delete", role: .destructive) {
114
+                if appData.deleteChargedDevice(id: chargedDeviceID) {
115
+                    dismiss()
116
+                }
117
+            }
118
+            Button("Cancel", role: .cancel) {}
119
+        } message: {
120
+            Text(deletionMessage)
121
+        }
122
+    }
123
+
124
+    private func headerCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
125
+        HStack(alignment: .top, spacing: 18) {
126
+            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 118)
127
+
128
+            VStack(alignment: .leading, spacing: 10) {
129
+                Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
130
+                    .font(.title3.weight(.bold))
131
+
132
+                Text(chargedDevice.deviceClass.title)
133
+                    .font(.subheadline.weight(.semibold))
134
+                    .foregroundColor(.secondary)
135
+
136
+                if let meterMAC = chargedDevice.lastAssociatedMeterMAC {
137
+                    Text("Default meter: \(meterMAC)")
138
+                        .font(.caption)
139
+                        .foregroundColor(.secondary)
140
+                }
141
+
142
+                Text(chargedDevice.qrIdentifier)
143
+                    .font(.caption2.monospaced())
144
+                    .foregroundColor(.secondary)
145
+                    .textSelection(.enabled)
146
+            }
147
+
148
+            Spacer(minLength: 0)
149
+        }
150
+        .frame(maxWidth: .infinity, alignment: .leading)
151
+        .padding(18)
152
+        .meterCard(tint: tint(for: chargedDevice), fillOpacity: 0.20, strokeOpacity: 0.26, cornerRadius: 20)
153
+    }
154
+
155
+    private func insightsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
156
+        MeterInfoCardView(title: "Insights", tint: tint(for: chargedDevice)) {
157
+            MeterInfoRowView(
158
+                label: "Supports Charging While Off",
159
+                value: chargedDevice.supportsChargingWhileOff ? "Yes" : "No"
160
+            )
161
+            MeterInfoRowView(
162
+                label: "Charging Support",
163
+                value: chargedDevice.supportedChargingModes.map(\.title).joined(separator: " + ")
164
+            )
165
+            MeterInfoRowView(
166
+                label: "Preferred Session Type",
167
+                value: chargedDevice.preferredChargingTransportMode.title
168
+            )
169
+            if chargedDevice.supportsWirelessCharging {
170
+                MeterInfoRowView(
171
+                    label: "Wireless Profile",
172
+                    value: chargedDevice.wirelessChargingProfile.title
173
+                )
174
+            }
175
+            if chargedDevice.supportsWiredCharging {
176
+                MeterInfoRowView(
177
+                    label: "Wired Completion Current",
178
+                    value: completionCurrentDescription(for: chargedDevice, chargingTransportMode: .wired)
179
+                )
180
+            }
181
+            if chargedDevice.supportsWirelessCharging {
182
+                MeterInfoRowView(
183
+                    label: "Wireless Completion Current",
184
+                    value: completionCurrentDescription(for: chargedDevice, chargingTransportMode: .wireless)
185
+                )
186
+            }
187
+            MeterInfoRowView(
188
+                label: "Estimated Capacity",
189
+                value: chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Not enough data"
190
+            )
191
+            if let wiredCapacity = chargedDevice.wiredEstimatedBatteryCapacityWh {
192
+                MeterInfoRowView(
193
+                    label: "Wired Capacity",
194
+                    value: "\(wiredCapacity.format(decimalDigits: 2)) Wh"
195
+                )
196
+            }
197
+            if let wirelessCapacity = chargedDevice.wirelessEstimatedBatteryCapacityWh {
198
+                MeterInfoRowView(
199
+                    label: "Wireless Capacity",
200
+                    value: "\(wirelessCapacity.format(decimalDigits: 2)) Wh"
201
+                )
202
+            }
203
+            if let wirelessEfficiencyFactor = chargedDevice.wirelessChargerEfficiencyFactor {
204
+                MeterInfoRowView(
205
+                    label: "Wireless Efficiency",
206
+                    value: "\(Int((wirelessEfficiencyFactor * 100).rounded()))%"
207
+                )
208
+            }
209
+            if chargedDevice.isCharger {
210
+                Divider()
211
+                if !chargedDevice.chargerObservedVoltageSelections.isEmpty {
212
+                    MeterInfoRowView(
213
+                        label: "Observed Voltages",
214
+                        value: chargedDevice.chargerObservedVoltageSelections
215
+                            .map { "\($0.format(decimalDigits: 1)) V" }
216
+                            .joined(separator: ", ")
217
+                    )
218
+                }
219
+                if let chargerIdleCurrentAmps = chargedDevice.chargerIdleCurrentAmps {
220
+                    MeterInfoRowView(
221
+                        label: "Idle Current",
222
+                        value: "\(chargerIdleCurrentAmps.format(decimalDigits: 2)) A"
223
+                    )
224
+                }
225
+                if let chargerEfficiencyFactor = chargedDevice.chargerEfficiencyFactor {
226
+                    MeterInfoRowView(
227
+                        label: "Efficiency",
228
+                        value: "\(Int((chargerEfficiencyFactor * 100).rounded()))%"
229
+                    )
230
+                }
231
+                if let chargerMaximumPowerWatts = chargedDevice.chargerMaximumPowerWatts {
232
+                    MeterInfoRowView(
233
+                        label: "Max Power",
234
+                        value: "\(chargerMaximumPowerWatts.format(decimalDigits: 2)) W"
235
+                    )
236
+                }
237
+            }
238
+            MeterInfoRowView(
239
+                label: "End-of-Charge Current",
240
+                value: chargedDevice.minimumCurrentAmps.map { "\($0.format(decimalDigits: 2)) A" } ?? "Learning"
241
+            )
242
+            MeterInfoRowView(
243
+                label: "Charge Sessions",
244
+                value: "\(chargedDevice.sessionCount)"
245
+            )
246
+
247
+            if let notes = chargedDevice.notes, !notes.isEmpty {
248
+                Divider()
249
+                Text(notes)
250
+                    .font(.footnote)
251
+                    .foregroundColor(.secondary)
252
+                    .frame(maxWidth: .infinity, alignment: .leading)
253
+            }
254
+        }
255
+    }
256
+
257
+    private func activeSessionCard(
258
+        _ activeSession: ChargeSessionSummary,
259
+        chargedDevice: ChargedDeviceSummary
260
+    ) -> some View {
261
+        MeterInfoCardView(title: "Active Session", tint: .green) {
262
+            MeterInfoRowView(label: "Started", value: activeSession.startedAt.format())
263
+            MeterInfoRowView(label: "Charging Type", value: activeSession.chargingTransportMode.title)
264
+            MeterInfoRowView(label: "Battery Energy", value: "\(activeSession.effectiveOrMeasuredEnergyWh.format(decimalDigits: 3)) Wh")
265
+            if activeSession.chargingTransportMode == .wireless,
266
+               let effectiveBatteryEnergyWh = activeSession.effectiveBatteryEnergyWh,
267
+               abs(effectiveBatteryEnergyWh - activeSession.measuredEnergyWh) > 0.01 {
268
+                MeterInfoRowView(label: "Charger Energy", value: "\(activeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh")
269
+            }
270
+            MeterInfoRowView(label: "Charge", value: "\(activeSession.measuredChargeAh.format(decimalDigits: 3)) Ah")
271
+            MeterInfoRowView(label: "Stop Threshold", value: "\(activeSession.stopThresholdAmps.format(decimalDigits: 2)) A")
272
+            MeterInfoRowView(label: "Source", value: activeSession.sourceMode.title)
273
+            if chargedDevice.isCharger == false,
274
+               let chargerID = activeSession.chargerID,
275
+               let charger = appData.chargedDeviceSummary(id: chargerID) {
276
+                MeterInfoRowView(label: "Wireless Charger", value: charger.name)
277
+            }
278
+            if let maximumObservedCurrentAmps = activeSession.maximumObservedCurrentAmps {
279
+                MeterInfoRowView(label: "Max Current", value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A")
280
+            }
281
+            if let maximumObservedPowerWatts = activeSession.maximumObservedPowerWatts {
282
+                MeterInfoRowView(label: "Max Power", value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W")
283
+            }
284
+            if activeSession.chargingTransportMode == .wired,
285
+               let maximumObservedVoltageVolts = activeSession.maximumObservedVoltageVolts {
286
+                MeterInfoRowView(label: "Max Voltage", value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V")
287
+            }
288
+            if chargedDevice.isCharger, let selectedSourceVoltageVolts = activeSession.selectedSourceVoltageVolts {
289
+                MeterInfoRowView(label: "Selected Voltage", value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V")
290
+            }
291
+            if let targetBatteryPercent = activeSession.targetBatteryPercent {
292
+                MeterInfoRowView(
293
+                    label: "Target Notification",
294
+                    value: "\(targetBatteryPercent.format(decimalDigits: 0))%"
295
+                )
296
+            }
297
+            if let wirelessSessionHint = wirelessSessionHint(for: activeSession) {
298
+                Text(wirelessSessionHint)
299
+                    .font(.caption2)
300
+                    .foregroundColor(activeSession.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
301
+            }
302
+            if let batteryPrediction = chargedDevice.batteryLevelPrediction(for: activeSession) {
303
+                MeterInfoRowView(
304
+                    label: "Predicted Battery",
305
+                    value: "\(batteryPrediction.predictedPercent.format(decimalDigits: 0))%"
306
+                )
307
+                Text(
308
+                    "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
309
+                )
310
+                .font(.caption2)
311
+                .foregroundColor(.secondary)
312
+            }
313
+
314
+            Button("Add Battery Checkpoint") {
315
+                checkpointEditorVisibility = true
316
+            }
317
+            .frame(maxWidth: .infinity)
318
+            .padding(.vertical, 10)
319
+            .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
320
+            .buttonStyle(.plain)
321
+
322
+            Button(activeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
323
+                targetNotificationEditorVisibility = true
324
+            }
325
+            .frame(maxWidth: .infinity)
326
+            .padding(.vertical, 10)
327
+            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
328
+            .buttonStyle(.plain)
329
+
330
+            if activeSession.targetBatteryPercent != nil {
331
+                Button("Clear Target Notification") {
332
+                    _ = appData.setTargetBatteryPercent(nil, for: activeSession.id)
333
+                }
334
+                .frame(maxWidth: .infinity)
335
+                .padding(.vertical, 10)
336
+                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
337
+                .buttonStyle(.plain)
338
+            }
339
+
340
+            Button(activeSession.requiresCompletionConfirmation ? "Finish Session" : "End Session") {
341
+                _ = appData.confirmChargeSessionCompletion(sessionID: activeSession.id)
342
+            }
343
+            .frame(maxWidth: .infinity)
344
+            .padding(.vertical, 10)
345
+            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
346
+            .buttonStyle(.plain)
347
+
348
+            if activeSession.requiresCompletionConfirmation {
349
+                Divider()
350
+                if let contradictionPercent = activeSession.completionContradictionPercent {
351
+                    Text("Current says charging may have stopped, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
352
+                        .font(.caption2)
353
+                        .foregroundColor(.secondary)
354
+                }
355
+
356
+                Button("Keep Monitoring") {
357
+                    _ = appData.continueChargeSessionMonitoring(sessionID: activeSession.id)
358
+                }
359
+                .frame(maxWidth: .infinity)
360
+                .padding(.vertical, 10)
361
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
362
+                .buttonStyle(.plain)
363
+            }
364
+        }
365
+    }
366
+
367
+    private func capacityEvolutionCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
368
+        VStack(alignment: .leading, spacing: 12) {
369
+            Text("Capacity Evolution")
370
+                .font(.headline)
371
+
372
+            ForEach(chargedDevice.capacityHistory.suffix(6)) { point in
373
+                HStack {
374
+                    Text(point.timestamp.format())
375
+                        .font(.caption)
376
+                        .foregroundColor(.secondary)
377
+                    Spacer()
378
+                    Text(point.chargingTransportMode.title)
379
+                        .font(.caption2)
380
+                        .foregroundColor(.secondary)
381
+                    Text("•")
382
+                        .foregroundColor(.secondary)
383
+                    Text("\(point.capacityWh.format(decimalDigits: 2)) Wh")
384
+                        .font(.footnote.weight(.semibold))
385
+                }
386
+            }
387
+        }
388
+        .frame(maxWidth: .infinity, alignment: .leading)
389
+        .padding(18)
390
+        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
391
+    }
392
+
393
+    private func preferredStoredCurveSession(for chargedDevice: ChargedDeviceSummary) -> ChargeSessionSummary? {
394
+        if let activeSession = chargedDevice.activeSession, !activeSession.aggregatedSamples.isEmpty {
395
+            return activeSession
396
+        }
397
+
398
+        return chargedDevice.sessions.first(where: { !$0.aggregatedSamples.isEmpty })
399
+    }
400
+
401
+    private func storedCurveCard(_ session: ChargeSessionSummary) -> some View {
402
+        let currentSeries = storedSeriesSnapshot(
403
+            from: session.aggregatedSamples,
404
+            minimumYSpan: 0.15
405
+        ) { $0.averageCurrentAmps }
406
+        let energySeries = storedSeriesSnapshot(
407
+            from: session.aggregatedSamples,
408
+            minimumYSpan: 0.2
409
+        ) { $0.measuredEnergyWh }
410
+
411
+        return VStack(alignment: .leading, spacing: 14) {
412
+            HStack(alignment: .firstTextBaseline) {
413
+                VStack(alignment: .leading, spacing: 4) {
414
+                    Text("Stored Session Curve")
415
+                        .font(.headline)
416
+                    Text(session.status == .active ? "Active session, persisted as aggregated samples." : "Most recent persisted session at aggregated resolution.")
417
+                        .font(.caption)
418
+                        .foregroundColor(.secondary)
419
+                }
420
+
421
+                Spacer()
422
+
423
+                Text("\(session.aggregatedSamples.count) points")
424
+                    .font(.caption.weight(.semibold))
425
+                    .foregroundColor(.secondary)
426
+            }
427
+
428
+            if let currentSeries {
429
+                storedSeriesChart(
430
+                    title: "Current",
431
+                    unit: "A",
432
+                    strokeColor: .blue,
433
+                    snapshot: currentSeries
434
+                )
435
+            }
436
+
437
+            if let energySeries {
438
+                storedSeriesChart(
439
+                    title: "Energy",
440
+                    unit: "Wh",
441
+                    strokeColor: .teal,
442
+                    areaChart: true,
443
+                    snapshot: energySeries
444
+                )
445
+            }
446
+
447
+            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.")
448
+                .font(.caption)
449
+                .foregroundColor(.secondary)
450
+        }
451
+        .frame(maxWidth: .infinity, alignment: .leading)
452
+        .padding(18)
453
+        .meterCard(tint: .indigo, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
454
+    }
455
+
456
+    private func typicalCurveCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
457
+        VStack(alignment: .leading, spacing: 12) {
458
+            Text("Typical Charge Curve")
459
+                .font(.headline)
460
+
461
+            ForEach(chargedDevice.typicalCurve) { point in
462
+                HStack {
463
+                    Text("\(point.percentBin)%")
464
+                        .font(.footnote.weight(.semibold))
465
+                    Spacer()
466
+                    Text("\(point.averageEnergyWh.format(decimalDigits: 2)) Wh")
467
+                        .font(.caption.weight(.semibold))
468
+                    Text("•")
469
+                        .foregroundColor(.secondary)
470
+                    Text("\(point.sampleCount) sample\(point.sampleCount == 1 ? "" : "s")")
471
+                        .font(.caption2)
472
+                        .foregroundColor(.secondary)
473
+                }
474
+            }
475
+        }
476
+        .frame(maxWidth: .infinity, alignment: .leading)
477
+        .padding(18)
478
+        .meterCard(tint: .blue, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
479
+    }
480
+
481
+    private func sessionsCard(_ chargedDevice: ChargedDeviceSummary) -> some View {
482
+        VStack(alignment: .leading, spacing: 12) {
483
+            Text("Charge Sessions")
484
+                .font(.headline)
485
+
486
+            Text("Use these summaries to spot odd sessions quickly before they influence device estimates.")
487
+                .font(.caption)
488
+                .foregroundColor(.secondary)
489
+
490
+            ForEach(chargedDevice.sessions, id: \.id) { session in
491
+                VStack(alignment: .leading, spacing: 6) {
492
+                    HStack(alignment: .firstTextBaseline, spacing: 10) {
493
+                        Text(session.startedAt.format())
494
+                            .font(.caption.weight(.semibold))
495
+                        Text(session.status.title)
496
+                            .font(.caption2.weight(.semibold))
497
+                            .padding(.horizontal, 8)
498
+                            .padding(.vertical, 4)
499
+                            .background(
500
+                                Capsule()
501
+                                    .fill(statusTint(for: session).opacity(0.16))
502
+                            )
503
+                        Spacer()
504
+                        Button {
505
+                            pendingSessionDeletion = session
506
+                        } label: {
507
+                            Image(systemName: "trash")
508
+                                .font(.caption.weight(.semibold))
509
+                                .foregroundColor(.red)
510
+                                .padding(8)
511
+                                .background(
512
+                                    Circle()
513
+                                        .fill(Color.red.opacity(0.10))
514
+                                )
515
+                        }
516
+                        .buttonStyle(.plain)
517
+                    }
518
+
519
+                    Text(sessionSummaryLine(session))
520
+                        .font(.caption2)
521
+                        .foregroundColor(.secondary)
522
+
523
+                    MeterInfoRowView(
524
+                        label: "Duration",
525
+                        value: sessionDurationText(session)
526
+                    )
527
+                    MeterInfoRowView(
528
+                        label: "Energy",
529
+                        value: "\(session.effectiveOrMeasuredEnergyWh.format(decimalDigits: 2)) Wh"
530
+                    )
531
+                    if session.chargingTransportMode == .wireless,
532
+                       let effectiveBatteryEnergyWh = session.effectiveBatteryEnergyWh,
533
+                       abs(effectiveBatteryEnergyWh - session.measuredEnergyWh) > 0.01 {
534
+                        MeterInfoRowView(
535
+                            label: "Charger Energy",
536
+                            value: "\(session.measuredEnergyWh.format(decimalDigits: 2)) Wh"
537
+                        )
538
+                    }
539
+                    if let maximumObservedCurrentAmps = session.maximumObservedCurrentAmps {
540
+                        MeterInfoRowView(
541
+                            label: "Max Current",
542
+                            value: "\(maximumObservedCurrentAmps.format(decimalDigits: 2)) A"
543
+                        )
544
+                    }
545
+                    if let maximumObservedPowerWatts = session.maximumObservedPowerWatts {
546
+                        MeterInfoRowView(
547
+                            label: "Max Power",
548
+                            value: "\(maximumObservedPowerWatts.format(decimalDigits: 2)) W"
549
+                        )
550
+                    }
551
+                    if session.chargingTransportMode == .wired,
552
+                       let maximumObservedVoltageVolts = session.maximumObservedVoltageVolts {
553
+                        MeterInfoRowView(
554
+                            label: "Max Voltage",
555
+                            value: "\(maximumObservedVoltageVolts.format(decimalDigits: 2)) V"
556
+                        )
557
+                    }
558
+                    if chargedDevice.isCharger, let selectedSourceVoltageVolts = session.selectedSourceVoltageVolts {
559
+                        MeterInfoRowView(
560
+                            label: "Selected Voltage",
561
+                            value: "\(selectedSourceVoltageVolts.format(decimalDigits: 2)) V"
562
+                        )
563
+                    }
564
+                    if let selectedDataGroup = session.selectedDataGroup {
565
+                        MeterInfoRowView(
566
+                            label: "Data Group",
567
+                            value: "#\(selectedDataGroup)"
568
+                        )
569
+                    }
570
+                    if chargedDevice.isCharger == false,
571
+                       let chargerID = session.chargerID,
572
+                       let charger = appData.chargedDeviceSummary(id: chargerID) {
573
+                        MeterInfoRowView(
574
+                            label: "Wireless Charger",
575
+                            value: charger.name
576
+                        )
577
+                    }
578
+                    if let wirelessSessionHint = wirelessSessionHint(for: session) {
579
+                        Text(wirelessSessionHint)
580
+                            .font(.caption2)
581
+                            .foregroundColor(session.shouldWarnAboutLowWirelessEfficiency ? .orange : .secondary)
582
+                    }
583
+                }
584
+                .padding(14)
585
+                .meterCard(
586
+                    tint: statusTint(for: session),
587
+                    fillOpacity: 0.10,
588
+                    strokeOpacity: 0.16,
589
+                    cornerRadius: 16
590
+                )
591
+            }
592
+        }
593
+        .frame(maxWidth: .infinity, alignment: .leading)
594
+        .padding(18)
595
+        .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20, cornerRadius: 18)
596
+    }
597
+
598
+    private func sessionSummaryLine(_ session: ChargeSessionSummary) -> String {
599
+        var components: [String] = []
600
+
601
+        if let batteryDeltaPercent = session.batteryDeltaPercent {
602
+            components.append("\(batteryDeltaPercent.format(decimalDigits: 0))%")
603
+        }
604
+
605
+        if let capacityEstimateWh = session.capacityEstimateWh {
606
+            components.append("\(capacityEstimateWh.format(decimalDigits: 2)) Wh capacity")
607
+        }
608
+
609
+        components.append(session.chargingTransportMode.title)
610
+        components.append(session.sourceMode.title)
611
+        return components.joined(separator: " • ")
612
+    }
613
+
614
+    private func wirelessSessionHint(for session: ChargeSessionSummary) -> String? {
615
+        guard session.chargingTransportMode == .wireless else {
616
+            return nil
617
+        }
618
+
619
+        var components: [String] = []
620
+        if let wirelessEfficiencyFactor = session.wirelessEfficiencyFactor {
621
+            components.append("Efficiency \(Int((wirelessEfficiencyFactor * 100).rounded()))%")
622
+        }
623
+        if session.usesEstimatedWirelessEfficiency {
624
+            components.append("Estimated from wired baseline and checkpoints")
625
+        }
626
+        if session.shouldWarnAboutLowWirelessEfficiency {
627
+            components.append("Low wireless efficiency, so capacity confidence is reduced")
628
+        }
629
+
630
+        return components.isEmpty ? nil : components.joined(separator: " • ")
631
+    }
632
+
633
+    private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
634
+        let formatter = DateComponentsFormatter()
635
+        formatter.allowedUnits = session.duration >= 3600 ? [.hour, .minute] : [.minute, .second]
636
+        formatter.unitsStyle = .abbreviated
637
+        formatter.zeroFormattingBehavior = .dropAll
638
+        return formatter.string(from: max(session.duration, 0)) ?? "0m"
639
+    }
640
+
641
+    private func statusTint(for session: ChargeSessionSummary) -> Color {
642
+        switch session.status {
643
+        case .active:
644
+            return .green
645
+        case .completed:
646
+            return .teal
647
+        case .abandoned:
648
+            return .orange
649
+        }
650
+    }
651
+
652
+    private func tint(for chargedDevice: ChargedDeviceSummary) -> Color {
653
+        switch chargedDevice.deviceClass {
654
+        case .iphone:
655
+            return .blue
656
+        case .watch:
657
+            return .green
658
+        case .powerbank:
659
+            return .orange
660
+        case .charger:
661
+            return .pink
662
+        case .other:
663
+            return .secondary
664
+        }
665
+    }
666
+
667
+    private func completionCurrentDescription(
668
+        for chargedDevice: ChargedDeviceSummary,
669
+        chargingTransportMode: ChargingTransportMode
670
+    ) -> String {
671
+        if let configuredCurrent = chargedDevice.configuredCompletionCurrentAmps(for: chargingTransportMode) {
672
+            if let learnedCurrent = chargedDevice.minimumCurrentAmps(for: chargingTransportMode),
673
+               abs(configuredCurrent - learnedCurrent) >= 0.01 {
674
+                return "\(configuredCurrent.format(decimalDigits: 2)) A configured • \(learnedCurrent.format(decimalDigits: 2)) A learned"
675
+            }
676
+            return "\(configuredCurrent.format(decimalDigits: 2)) A configured"
677
+        }
678
+
679
+        if let learnedCurrent = chargedDevice.minimumCurrentAmps(for: chargingTransportMode) {
680
+            return "\(learnedCurrent.format(decimalDigits: 2)) A learned"
681
+        }
682
+
683
+        return "Learning"
684
+    }
685
+
686
+    private var deletionTitle: String {
687
+        appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true ? "charger" : "device"
688
+    }
689
+
690
+    private var deletionMessage: String {
691
+        if appData.chargedDeviceSummary(id: chargedDeviceID)?.isCharger == true {
692
+            return "This removes the charger from the library and unlinks it from wireless sessions that used it."
693
+        }
694
+        return "This removes the device and its stored charging history from the library."
695
+    }
696
+
697
+    private func storedSeriesSnapshot(
698
+        from samples: [ChargeSessionSampleSummary],
699
+        minimumYSpan: Double,
700
+        value: (ChargeSessionSampleSummary) -> Double
701
+    ) -> StoredSeriesSnapshot? {
702
+        let sortedSamples = samples.sorted { lhs, rhs in
703
+            if lhs.bucketIndex != rhs.bucketIndex {
704
+                return lhs.bucketIndex < rhs.bucketIndex
705
+            }
706
+            return lhs.timestamp < rhs.timestamp
707
+        }
708
+
709
+        guard
710
+            let firstSample = sortedSamples.first,
711
+            let lastSample = sortedSamples.last
712
+        else {
713
+            return nil
714
+        }
715
+
716
+        let points = sortedSamples.enumerated().map { index, sample in
717
+            Measurements.Measurement.Point(
718
+                id: index,
719
+                timestamp: sample.timestamp,
720
+                value: value(sample),
721
+                kind: .sample
722
+            )
723
+        }
724
+
725
+        let minimumValue = points.map(\.value).min() ?? 0
726
+        let maximumValue = points.map(\.value).max() ?? minimumValue
727
+        let context = ChartContext()
728
+        context.setBounds(
729
+            xMin: CGFloat(firstSample.timestamp.timeIntervalSince1970),
730
+            xMax: CGFloat(max(lastSample.timestamp.timeIntervalSince1970, firstSample.timestamp.timeIntervalSince1970 + 1)),
731
+            yMin: CGFloat(minimumValue),
732
+            yMax: CGFloat(maximumValue)
733
+        )
734
+        context.ensureMinimumSize(width: 1, height: CGFloat(max(maximumValue - minimumValue, minimumYSpan)))
735
+
736
+        return StoredSeriesSnapshot(
737
+            points: points,
738
+            context: context,
739
+            minimumValue: minimumValue,
740
+            maximumValue: maximumValue
741
+        )
742
+    }
743
+
744
+    private func storedSeriesChart(
745
+        title: String,
746
+        unit: String,
747
+        strokeColor: Color,
748
+        areaChart: Bool = false,
749
+        snapshot: StoredSeriesSnapshot
750
+    ) -> some View {
751
+        VStack(alignment: .leading, spacing: 8) {
752
+            HStack(alignment: .firstTextBaseline) {
753
+                Text(title)
754
+                    .font(.subheadline.weight(.semibold))
755
+                Spacer()
756
+                Text(
757
+                    "Latest \(snapshot.lastValue.format(decimalDigits: 2)) \(unit) • \(snapshot.minimumValue.format(decimalDigits: 2))-\(snapshot.maximumValue.format(decimalDigits: 2)) \(unit)"
758
+                )
759
+                .font(.caption2)
760
+                .foregroundColor(.secondary)
761
+            }
762
+
763
+            Chart(
764
+                points: snapshot.points,
765
+                context: snapshot.context,
766
+                areaChart: areaChart,
767
+                strokeColor: strokeColor
768
+            )
769
+            .frame(height: 118)
770
+            .padding(.horizontal, 6)
771
+            .padding(.vertical, 8)
772
+            .background(
773
+                RoundedRectangle(cornerRadius: 16)
774
+                    .fill(strokeColor.opacity(areaChart ? 0.08 : 0.06))
775
+            )
776
+
777
+            HStack {
778
+                Text(snapshot.startLabel)
779
+                Spacer()
780
+                Text(snapshot.endLabel)
781
+            }
782
+            .font(.caption2)
783
+            .foregroundColor(.secondary)
784
+        }
785
+    }
786
+}
787
+
788
+private struct StoredSeriesSnapshot {
789
+    let points: [Measurements.Measurement.Point]
790
+    let context: ChartContext
791
+    let minimumValue: Double
792
+    let maximumValue: Double
793
+
794
+    var lastValue: Double {
795
+        points.last?.value ?? 0
796
+    }
797
+
798
+    var startLabel: String {
799
+        guard let firstTimestamp = points.first?.timestamp else { return "" }
800
+        return firstTimestamp.formatted(date: .omitted, time: .shortened)
801
+    }
802
+
803
+    var endLabel: String {
804
+        guard let lastTimestamp = points.last?.timestamp else { return "" }
805
+        return lastTimestamp.formatted(date: .omitted, time: .shortened)
806
+    }
807
+}
808
+
809
+private struct ChargedDeviceCheckpointEditorSheetView: View {
810
+    @Environment(\.dismiss) private var dismiss
811
+    @EnvironmentObject private var appData: AppData
812
+
813
+    let sessionID: UUID
814
+
815
+    @State private var batteryPercent = ""
816
+    @State private var label = ""
817
+
818
+    var body: some View {
819
+        NavigationView {
820
+            Form {
821
+                Section(header: Text("Checkpoint")) {
822
+                    TextField("Battery %", text: $batteryPercent)
823
+                        .keyboardType(.decimalPad)
824
+                    TextField("Label (optional)", text: $label)
825
+                }
826
+
827
+                Section {
828
+                    Text("The checkpoint is stored on the active charge session and is used for capacity estimation and charge-level prediction.")
829
+                        .font(.footnote)
830
+                        .foregroundColor(.secondary)
831
+                }
832
+            }
833
+            .navigationTitle("Battery Checkpoint")
834
+            .navigationBarTitleDisplayMode(.inline)
835
+            .toolbar {
836
+                ToolbarItem(placement: .cancellationAction) {
837
+                    Button("Cancel") {
838
+                        dismiss()
839
+                    }
840
+                }
841
+
842
+                ToolbarItem(placement: .confirmationAction) {
843
+                    Button("Save") {
844
+                        guard let percent = Double(batteryPercent) else {
845
+                            return
846
+                        }
847
+
848
+                        if appData.addBatteryCheckpoint(percent: percent, label: label, for: sessionID) {
849
+                            dismiss()
850
+                        }
851
+                    }
852
+                    .disabled(
853
+                        (Double(batteryPercent) ?? -1) < 0
854
+                            || (Double(batteryPercent) ?? 101) > 100
855
+                    )
856
+                }
857
+            }
858
+        }
859
+        .navigationViewStyle(StackNavigationViewStyle())
860
+    }
861
+}
862
+
863
+private struct ChargedDeviceTargetNotificationEditorSheetView: View {
864
+    @Environment(\.dismiss) private var dismiss
865
+    @EnvironmentObject private var appData: AppData
866
+
867
+    let sessionID: UUID
868
+    let initialTargetPercent: Double?
869
+
870
+    @State private var targetPercent: Double
871
+
872
+    init(sessionID: UUID, initialTargetPercent: Double?) {
873
+        self.sessionID = sessionID
874
+        self.initialTargetPercent = initialTargetPercent
875
+        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
876
+    }
877
+
878
+    var body: some View {
879
+        NavigationView {
880
+            Form {
881
+                Section(header: Text("Target Level")) {
882
+                    VStack(alignment: .leading, spacing: 12) {
883
+                        Text("\(targetPercent.format(decimalDigits: 0))%")
884
+                            .font(.title3.weight(.bold))
885
+                        Slider(value: $targetPercent, in: 20...100, step: 1)
886
+                        Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
887
+                            .font(.footnote)
888
+                            .foregroundColor(.secondary)
889
+                    }
890
+                }
891
+            }
892
+            .navigationTitle("Battery Target")
893
+            .navigationBarTitleDisplayMode(.inline)
894
+            .toolbar {
895
+                ToolbarItem(placement: .cancellationAction) {
896
+                    Button("Cancel") {
897
+                        dismiss()
898
+                    }
899
+                }
900
+
901
+                ToolbarItem(placement: .confirmationAction) {
902
+                    Button("Save") {
903
+                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
904
+                            dismiss()
905
+                        }
906
+                    }
907
+                }
908
+            }
909
+        }
910
+        .navigationViewStyle(StackNavigationViewStyle())
911
+    }
912
+}
+272 -0
USB Meter/Views/ChargedDevices/ChargedDeviceEditorSheetView.swift
@@ -0,0 +1,272 @@
1
+//
2
+//  ChargedDeviceEditorSheetView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct ChargedDeviceEditorSheetView: View {
11
+    @EnvironmentObject private var appData: AppData
12
+    @Environment(\.dismiss) private var dismiss
13
+
14
+    let meterMACAddress: String?
15
+    let chargedDevice: ChargedDeviceSummary?
16
+    let suggestedDeviceClass: ChargedDeviceClass?
17
+
18
+    @State private var name: String
19
+    @State private var deviceClass: ChargedDeviceClass
20
+    @State private var supportsChargingWhileOff: Bool
21
+    @State private var supportsWiredCharging: Bool
22
+    @State private var supportsWirelessCharging: Bool
23
+    @State private var preferredChargingTransportMode: ChargingTransportMode
24
+    @State private var wirelessChargingProfile: WirelessChargingProfile
25
+    @State private var wiredChargeCompletionCurrentText: String
26
+    @State private var wirelessChargeCompletionCurrentText: String
27
+    @State private var notes: String
28
+
29
+    init(
30
+        meterMACAddress: String?,
31
+        chargedDevice: ChargedDeviceSummary? = nil,
32
+        suggestedDeviceClass: ChargedDeviceClass? = nil
33
+    ) {
34
+        self.meterMACAddress = meterMACAddress
35
+        self.chargedDevice = chargedDevice
36
+        self.suggestedDeviceClass = suggestedDeviceClass
37
+        let initialDeviceClass = chargedDevice?.deviceClass ?? suggestedDeviceClass ?? .iphone
38
+        _name = State(initialValue: chargedDevice?.name ?? "")
39
+        _deviceClass = State(initialValue: initialDeviceClass)
40
+        _supportsChargingWhileOff = State(initialValue: chargedDevice?.supportsChargingWhileOff ?? false)
41
+        _supportsWiredCharging = State(initialValue: chargedDevice?.supportsWiredCharging ?? true)
42
+        _supportsWirelessCharging = State(initialValue: chargedDevice?.supportsWirelessCharging ?? true)
43
+        _preferredChargingTransportMode = State(initialValue: chargedDevice?.preferredChargingTransportMode ?? .wired)
44
+        _wirelessChargingProfile = State(initialValue: chargedDevice?.wirelessChargingProfile ?? .genericQi)
45
+        _wiredChargeCompletionCurrentText = State(initialValue: Self.optionalCurrentText(chargedDevice?.wiredChargeCompletionCurrentAmps))
46
+        _wirelessChargeCompletionCurrentText = State(initialValue: Self.optionalCurrentText(chargedDevice?.wirelessChargeCompletionCurrentAmps))
47
+        _notes = State(initialValue: chargedDevice?.notes ?? "")
48
+    }
49
+
50
+    var body: some View {
51
+        NavigationView {
52
+            Form {
53
+                Section(header: Text("Identity")) {
54
+                    TextField("Name", text: $name)
55
+
56
+                    Picker("Class", selection: $deviceClass) {
57
+                        ForEach(ChargedDeviceClass.allCases) { deviceClass in
58
+                            Label(deviceClass.title, systemImage: deviceClass.symbolName)
59
+                                .tag(deviceClass)
60
+                        }
61
+                    }
62
+
63
+                    if let chargedDevice {
64
+                        Text(chargedDevice.qrIdentifier)
65
+                            .font(.caption.monospaced())
66
+                            .foregroundColor(.secondary)
67
+                            .textSelection(.enabled)
68
+                    }
69
+                }
70
+
71
+                Section(header: Text("Charge Behaviour")) {
72
+                    Toggle("Can finish charging while device is off", isOn: $supportsChargingWhileOff)
73
+                    Text("This flag is used when we decide which charge sessions are reliable enough to estimate battery capacity.")
74
+                        .font(.footnote)
75
+                        .foregroundColor(.secondary)
76
+                }
77
+
78
+                Section(header: Text("Charging Support")) {
79
+                    Toggle("Supports wired charging", isOn: $supportsWiredCharging)
80
+                    Toggle("Supports wireless charging", isOn: $supportsWirelessCharging)
81
+
82
+                    if supportsWirelessCharging {
83
+                        Picker("Wireless profile", selection: $wirelessChargingProfile) {
84
+                            ForEach(WirelessChargingProfile.allCases) { profile in
85
+                                Text(profile.title)
86
+                                    .tag(profile)
87
+                            }
88
+                        }
89
+
90
+                        Text(wirelessChargingProfile.description)
91
+                            .font(.footnote)
92
+                            .foregroundColor(.secondary)
93
+                    }
94
+
95
+                    if supportsWiredCharging || supportsWirelessCharging {
96
+                        Picker("Default session type", selection: preferredChargingTransportBinding) {
97
+                            ForEach(supportedChargingModes) { chargingTransportMode in
98
+                                Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
99
+                                    .tag(chargingTransportMode)
100
+                            }
101
+                        }
102
+                    } else {
103
+                        Text("Enable at least one charging method.")
104
+                            .font(.footnote)
105
+                            .foregroundColor(.secondary)
106
+                    }
107
+                }
108
+
109
+                Section(header: Text("Charge Completion")) {
110
+                    if supportsWiredCharging {
111
+                        TextField("Wired completion current (A)", text: $wiredChargeCompletionCurrentText)
112
+                            .keyboardType(.decimalPad)
113
+                        Text("Leave empty to keep learning this value from wired sessions.")
114
+                            .font(.footnote)
115
+                            .foregroundColor(.secondary)
116
+                    }
117
+
118
+                    if supportsWirelessCharging {
119
+                        TextField("Wireless completion current (A)", text: $wirelessChargeCompletionCurrentText)
120
+                            .keyboardType(.decimalPad)
121
+                        Text("Leave empty to keep learning this value from wireless sessions.")
122
+                            .font(.footnote)
123
+                            .foregroundColor(.secondary)
124
+                    }
125
+                }
126
+
127
+                Section(header: Text("Notes")) {
128
+                    TextField("Optional notes", text: $notes)
129
+                }
130
+            }
131
+            .navigationTitle(editorTitle)
132
+            .navigationBarTitleDisplayMode(.inline)
133
+            .toolbar {
134
+                ToolbarItem(placement: .cancellationAction) {
135
+                    Button("Cancel") {
136
+                        dismiss()
137
+                    }
138
+                }
139
+                ToolbarItem(placement: .confirmationAction) {
140
+                    Button(saveButtonTitle) {
141
+                        let didSave: Bool
142
+                        if let chargedDevice {
143
+                            didSave = appData.updateChargedDevice(
144
+                                id: chargedDevice.id,
145
+                                name: name,
146
+                                deviceClass: deviceClass,
147
+                                supportsChargingWhileOff: supportsChargingWhileOff,
148
+                                supportsWiredCharging: supportsWiredCharging,
149
+                                supportsWirelessCharging: supportsWirelessCharging,
150
+                                preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
151
+                                wirelessChargingProfile: wirelessChargingProfile,
152
+                                wiredChargeCompletionCurrentAmps: parsedOptionalCurrent(wiredChargeCompletionCurrentText),
153
+                                wirelessChargeCompletionCurrentAmps: parsedOptionalCurrent(wirelessChargeCompletionCurrentText),
154
+                                notes: notes
155
+                            )
156
+                        } else {
157
+                            didSave = appData.createChargedDevice(
158
+                                name: name,
159
+                                deviceClass: deviceClass,
160
+                                supportsChargingWhileOff: supportsChargingWhileOff,
161
+                                supportsWiredCharging: supportsWiredCharging,
162
+                                supportsWirelessCharging: supportsWirelessCharging,
163
+                                preferredChargingTransportMode: resolvedPreferredChargingTransportMode,
164
+                                wirelessChargingProfile: wirelessChargingProfile,
165
+                                wiredChargeCompletionCurrentAmps: parsedOptionalCurrent(wiredChargeCompletionCurrentText),
166
+                                wirelessChargeCompletionCurrentAmps: parsedOptionalCurrent(wirelessChargeCompletionCurrentText),
167
+                                notes: notes,
168
+                                meterMACAddress: meterMACAddress
169
+                            )
170
+                        }
171
+
172
+                        if didSave {
173
+                            dismiss()
174
+                        }
175
+                    }
176
+                    .disabled(
177
+                        name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
178
+                            || (!supportsWiredCharging && !supportsWirelessCharging)
179
+                    )
180
+                }
181
+            }
182
+        }
183
+        .navigationViewStyle(StackNavigationViewStyle())
184
+        .onChange(of: deviceClass) { newValue in
185
+            applySuggestedChargingSupport(for: newValue)
186
+        }
187
+        .onAppear {
188
+            guard chargedDevice == nil else {
189
+                return
190
+            }
191
+            applySuggestedChargingSupport(for: deviceClass)
192
+        }
193
+    }
194
+
195
+    private var editorTitle: String {
196
+        if chargedDevice == nil {
197
+            return deviceClass == .charger ? "New Charger" : "New Device"
198
+        }
199
+        return chargedDevice?.isCharger == true ? "Edit Charger" : "Edit Device"
200
+    }
201
+
202
+    private var saveButtonTitle: String {
203
+        chargedDevice == nil ? "Save" : "Update"
204
+    }
205
+
206
+    private var supportedChargingModes: [ChargingTransportMode] {
207
+        var modes: [ChargingTransportMode] = []
208
+        if supportsWiredCharging {
209
+            modes.append(.wired)
210
+        }
211
+        if supportsWirelessCharging {
212
+            modes.append(.wireless)
213
+        }
214
+        return modes
215
+    }
216
+
217
+    private var resolvedPreferredChargingTransportMode: ChargingTransportMode {
218
+        if supportedChargingModes.contains(preferredChargingTransportMode) {
219
+            return preferredChargingTransportMode
220
+        }
221
+        return supportsWiredCharging ? .wired : .wireless
222
+    }
223
+
224
+    private var preferredChargingTransportBinding: Binding<ChargingTransportMode> {
225
+        Binding(
226
+            get: { resolvedPreferredChargingTransportMode },
227
+            set: { preferredChargingTransportMode = $0 }
228
+        )
229
+    }
230
+
231
+    private func applySuggestedChargingSupport(for deviceClass: ChargedDeviceClass) {
232
+        switch deviceClass {
233
+        case .iphone:
234
+            supportsWiredCharging = true
235
+            supportsWirelessCharging = true
236
+            preferredChargingTransportMode = .wired
237
+        case .watch:
238
+            supportsWiredCharging = false
239
+            supportsWirelessCharging = true
240
+            preferredChargingTransportMode = .wireless
241
+        case .powerbank:
242
+            supportsWiredCharging = true
243
+            supportsWirelessCharging = false
244
+            preferredChargingTransportMode = .wired
245
+        case .charger:
246
+            supportsWiredCharging = true
247
+            supportsWirelessCharging = true
248
+            preferredChargingTransportMode = .wireless
249
+        case .other:
250
+            supportsWiredCharging = true
251
+            supportsWirelessCharging = false
252
+            preferredChargingTransportMode = .wired
253
+        }
254
+    }
255
+
256
+    private func parsedOptionalCurrent(_ text: String) -> Double? {
257
+        let normalized = text
258
+            .trimmingCharacters(in: .whitespacesAndNewlines)
259
+            .replacingOccurrences(of: ",", with: ".")
260
+        guard !normalized.isEmpty else {
261
+            return nil
262
+        }
263
+        return Double(normalized)
264
+    }
265
+
266
+    private static func optionalCurrentText(_ value: Double?) -> String {
267
+        guard let value else {
268
+            return ""
269
+        }
270
+        return value.format(decimalDigits: 2)
271
+    }
272
+}
+223 -0
USB Meter/Views/ChargedDevices/ChargedDeviceLibrarySheetView.swift
@@ -0,0 +1,223 @@
1
+//
2
+//  ChargedDeviceLibrarySheetView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+enum ChargedDeviceLibraryMode {
11
+    case device
12
+    case charger
13
+
14
+    var title: String {
15
+        switch self {
16
+        case .device:
17
+            return "Devices"
18
+        case .charger:
19
+            return "Chargers"
20
+        }
21
+    }
22
+
23
+    var singularTitle: String {
24
+        switch self {
25
+        case .device:
26
+            return "Device"
27
+        case .charger:
28
+            return "Charger"
29
+        }
30
+    }
31
+
32
+    var suggestedClass: ChargedDeviceClass {
33
+        switch self {
34
+        case .device:
35
+            return .iphone
36
+        case .charger:
37
+            return .charger
38
+        }
39
+    }
40
+}
41
+
42
+struct ChargedDeviceLibrarySheetView: View {
43
+    @EnvironmentObject private var appData: AppData
44
+
45
+    @Binding var visibility: Bool
46
+
47
+    let meterMACAddress: String
48
+    let meterTint: Color
49
+    let mode: ChargedDeviceLibraryMode
50
+
51
+    @State private var editorVisibility = false
52
+    @State private var editingChargedDevice: ChargedDeviceSummary?
53
+
54
+    var body: some View {
55
+        NavigationView {
56
+            List {
57
+                if displayedChargedDevices.isEmpty {
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)
64
+                    }
65
+                    .padding(.vertical, 10)
66
+                    .listRowBackground(Color.clear)
67
+                } else {
68
+                    ForEach(displayedChargedDevices) { chargedDevice in
69
+                        Button {
70
+                            select(chargedDevice)
71
+                            visibility = false
72
+                        } label: {
73
+                            ChargedDeviceLibraryRowView(
74
+                                chargedDevice: chargedDevice,
75
+                                isSelected: chargedDevice.id == selectedDeviceID
76
+                            )
77
+                        }
78
+                        .buttonStyle(.plain)
79
+                        .swipeActions(edge: .trailing, allowsFullSwipe: false) {
80
+                            Button {
81
+                                editingChargedDevice = chargedDevice
82
+                            } label: {
83
+                                Label("Edit", systemImage: "pencil")
84
+                            }
85
+                            .tint(.blue)
86
+                        }
87
+                        .contextMenu {
88
+                            Button {
89
+                                editingChargedDevice = chargedDevice
90
+                            } label: {
91
+                                Label("Edit Device", systemImage: "pencil")
92
+                            }
93
+                        }
94
+                    }
95
+                }
96
+            }
97
+            .listStyle(InsetGroupedListStyle())
98
+            .background(
99
+                LinearGradient(
100
+                    colors: [meterTint.opacity(0.14), Color.clear],
101
+                    startPoint: .topLeading,
102
+                    endPoint: .bottomTrailing
103
+                )
104
+                .ignoresSafeArea()
105
+            )
106
+            .navigationTitle(mode.title)
107
+            .navigationBarTitleDisplayMode(.inline)
108
+            .toolbar {
109
+                ToolbarItem(placement: .cancellationAction) {
110
+                    Button("Done") {
111
+                        visibility = false
112
+                    }
113
+                }
114
+                ToolbarItem(placement: .confirmationAction) {
115
+                    Button("New") {
116
+                        editorVisibility = true
117
+                    }
118
+                }
119
+            }
120
+        }
121
+        .navigationViewStyle(StackNavigationViewStyle())
122
+        .sheet(isPresented: $editorVisibility) {
123
+            ChargedDeviceEditorSheetView(
124
+                meterMACAddress: meterMACAddress,
125
+                suggestedDeviceClass: mode.suggestedClass
126
+            )
127
+                .environmentObject(appData)
128
+        }
129
+        .sheet(item: $editingChargedDevice) { chargedDevice in
130
+            ChargedDeviceEditorSheetView(
131
+                meterMACAddress: nil,
132
+                chargedDevice: chargedDevice,
133
+                suggestedDeviceClass: mode.suggestedClass
134
+            )
135
+            .environmentObject(appData)
136
+        }
137
+    }
138
+
139
+    private var displayedChargedDevices: [ChargedDeviceSummary] {
140
+        switch mode {
141
+        case .device:
142
+            return appData.deviceSummaries
143
+        case .charger:
144
+            return appData.chargerSummaries
145
+        }
146
+    }
147
+
148
+    private var selectedDeviceID: UUID? {
149
+        switch mode {
150
+        case .device:
151
+            return appData.currentChargedDeviceSummary(for: meterMACAddress)?.id
152
+        case .charger:
153
+            return appData.currentChargerSummary(for: meterMACAddress)?.id
154
+        }
155
+    }
156
+
157
+    private var emptyStateDescription: String {
158
+        switch mode {
159
+        case .device:
160
+            return "Create one here, then select it before or during a charging session. The selected device becomes the default for this meter."
161
+        case .charger:
162
+            return "Create one here, then select it for wireless charging sessions. The selected charger becomes the default wireless source for this meter."
163
+        }
164
+    }
165
+
166
+    private func select(_ chargedDevice: ChargedDeviceSummary) {
167
+        switch mode {
168
+        case .device:
169
+            appData.assignChargedDevice(chargedDevice.id, to: meterMACAddress)
170
+        case .charger:
171
+            appData.assignCharger(chargedDevice.id, to: meterMACAddress)
172
+        }
173
+    }
174
+}
175
+
176
+private struct ChargedDeviceLibraryRowView: View {
177
+    let chargedDevice: ChargedDeviceSummary
178
+    let isSelected: Bool
179
+
180
+    var body: some View {
181
+        HStack(alignment: .top, spacing: 14) {
182
+            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 58)
183
+
184
+            VStack(alignment: .leading, spacing: 6) {
185
+                HStack {
186
+                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
187
+                        .font(.headline)
188
+                        .foregroundColor(.primary)
189
+                    Spacer()
190
+                    if isSelected {
191
+                        Image(systemName: "checkmark.circle.fill")
192
+                            .foregroundColor(.green)
193
+                    }
194
+                }
195
+
196
+                Text(chargedDevice.deviceClass.title)
197
+                    .font(.caption.weight(.semibold))
198
+                    .foregroundColor(.secondary)
199
+
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)
211
+                        .foregroundColor(.secondary)
212
+                }
213
+
214
+                if let minimumCurrent = chargedDevice.resolvedCompletionCurrentAmps(for: chargedDevice.preferredChargingTransportMode) {
215
+                    Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
216
+                        .font(.caption2)
217
+                        .foregroundColor(.secondary)
218
+                }
219
+            }
220
+        }
221
+        .padding(.vertical, 4)
222
+    }
223
+}
+60 -0
USB Meter/Views/ChargedDevices/ChargedDeviceQRCodeView.swift
@@ -0,0 +1,60 @@
1
+//
2
+//  ChargedDeviceQRCodeView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import CoreImage.CIFilterBuiltins
9
+import SwiftUI
10
+
11
+struct ChargedDeviceQRCodeView: View {
12
+    let qrIdentifier: String
13
+    let side: CGFloat
14
+
15
+    private let context = CIContext()
16
+    private let filter = CIFilter.qrCodeGenerator()
17
+
18
+    var body: some View {
19
+        Group {
20
+            if let image = qrImage {
21
+                Image(uiImage: image)
22
+                    .interpolation(.none)
23
+                    .resizable()
24
+                    .scaledToFit()
25
+            } else {
26
+                RoundedRectangle(cornerRadius: 14)
27
+                    .fill(Color.secondary.opacity(0.12))
28
+                    .overlay(
29
+                        Image(systemName: "qrcode")
30
+                            .font(.system(size: side * 0.34, weight: .semibold))
31
+                            .foregroundColor(.secondary)
32
+                    )
33
+            }
34
+        }
35
+        .frame(width: side, height: side)
36
+        .padding(8)
37
+        .background(
38
+            RoundedRectangle(cornerRadius: 18)
39
+                .fill(Color.white.opacity(0.92))
40
+        )
41
+    }
42
+
43
+    private var qrImage: UIImage? {
44
+        filter.setValue(Data(qrIdentifier.utf8), forKey: "inputMessage")
45
+        filter.correctionLevel = "M"
46
+
47
+        guard let outputImage = filter.outputImage else {
48
+            return nil
49
+        }
50
+
51
+        let transform = CGAffineTransform(scaleX: 12, y: 12)
52
+        let scaledImage = outputImage.transformed(by: transform)
53
+
54
+        guard let cgImage = context.createCGImage(scaledImage, from: scaledImage.extent) else {
55
+            return nil
56
+        }
57
+
58
+        return UIImage(cgImage: cgImage)
59
+    }
60
+}
+101 -0
USB Meter/Views/ChargedDevices/SidebarChargedDevicesSectionView.swift
@@ -0,0 +1,101 @@
1
+//
2
+//  SidebarChargedDevicesSectionView.swift
3
+//  USB Meter
4
+//
5
+//  Created by Codex on 10/04/2026.
6
+//
7
+
8
+import SwiftUI
9
+
10
+struct SidebarChargedDevicesSectionView: View {
11
+    let title: String
12
+    let chargedDevices: [ChargedDeviceSummary]
13
+    let emptyStateText: String
14
+    let tint: Color
15
+    let onAdd: () -> Void
16
+
17
+    var body: some View {
18
+        Section(header: headerView) {
19
+            if chargedDevices.isEmpty {
20
+                Text(emptyStateText)
21
+                    .font(.footnote)
22
+                    .foregroundColor(.secondary)
23
+                    .frame(maxWidth: .infinity, alignment: .leading)
24
+                    .padding(18)
25
+                    .meterCard(tint: tint, fillOpacity: 0.12, strokeOpacity: 0.18)
26
+            } else {
27
+                ForEach(chargedDevices) { chargedDevice in
28
+                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
29
+                        ChargedDeviceSidebarCardView(chargedDevice: chargedDevice)
30
+                    }
31
+                    .buttonStyle(.plain)
32
+                }
33
+            }
34
+        }
35
+    }
36
+
37
+    private var headerView: some View {
38
+        HStack(alignment: .firstTextBaseline, spacing: 10) {
39
+            Text(title)
40
+                .font(.headline)
41
+            Spacer()
42
+            Button(action: onAdd) {
43
+                Image(systemName: "plus.circle.fill")
44
+                    .font(.body.weight(.semibold))
45
+                    .foregroundColor(tint)
46
+            }
47
+            .buttonStyle(.plain)
48
+            Text("\(chargedDevices.count)")
49
+                .font(.caption.weight(.bold))
50
+                .padding(.horizontal, 10)
51
+                .padding(.vertical, 6)
52
+                .meterCard(tint: tint, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999)
53
+        }
54
+    }
55
+}
56
+
57
+private struct ChargedDeviceSidebarCardView: View {
58
+    let chargedDevice: ChargedDeviceSummary
59
+
60
+    var body: some View {
61
+        HStack(alignment: .top, spacing: 12) {
62
+            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 54)
63
+
64
+            VStack(alignment: .leading, spacing: 6) {
65
+                HStack {
66
+                    Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
67
+                        .font(.headline)
68
+                    if chargedDevice.activeSession != nil {
69
+                        Spacer()
70
+                        Text("Live")
71
+                            .font(.caption.weight(.bold))
72
+                            .foregroundColor(.green)
73
+                    }
74
+                }
75
+
76
+                Text(chargedDevice.deviceClass.title)
77
+                    .font(.caption.weight(.semibold))
78
+                    .foregroundColor(.secondary)
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)
92
+                } else {
93
+                    Text("Capacity: learning")
94
+                        .font(.caption2)
95
+                        .foregroundColor(.secondary)
96
+                }
97
+            }
98
+        }
99
+        .padding(.vertical, 4)
100
+    }
101
+}
+433 -0
USB Meter/Views/Meter/Sheets/ChargeRecord/ChargeRecordSheetView.swift
@@ -11,7 +11,13 @@ import SwiftUI
11 11
 struct ChargeRecordSheetView: View {
12 12
     
13 13
     @Binding var visibility: Bool
14
+    @EnvironmentObject private var appData: AppData
14 15
     @EnvironmentObject private var usbMeter: Meter
16
+    @State private var chargedDeviceLibraryVisibility = false
17
+    @State private var chargerLibraryVisibility = false
18
+    @State private var checkpointEditorVisibility = false
19
+    @State private var editingChargedDevice: ChargedDeviceSummary?
20
+    @State private var targetNotificationEditorVisibility = false
15 21
     
16 22
     var body: some View {
17 23
         NavigationView {
@@ -42,6 +48,12 @@ struct ChargeRecordSheetView: View {
42 48
                     .padding(18)
43 49
                     .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
44 50
 
51
+                    chargedDeviceSection
52
+
53
+                    if let activeChargeSession {
54
+                        chargeMonitorSection(activeChargeSession)
55
+                    }
56
+
45 57
                     ChargeRecordMetricsTableView(
46 58
                         labels: ["Capacity", "Energy", "Duration", "Stop Threshold"],
47 59
                         values: [
@@ -138,6 +150,377 @@ struct ChargeRecordSheetView: View {
138 150
             .navigationBarItems(trailing: Button("Done") { visibility.toggle() })
139 151
         }
140 152
         .navigationViewStyle(StackNavigationViewStyle())
153
+        .sheet(isPresented: $chargedDeviceLibraryVisibility) {
154
+            ChargedDeviceLibrarySheetView(
155
+                visibility: $chargedDeviceLibraryVisibility,
156
+                meterMACAddress: usbMeter.btSerial.macAddress.description,
157
+                meterTint: usbMeter.color,
158
+                mode: .device
159
+            )
160
+            .environmentObject(appData)
161
+        }
162
+        .sheet(isPresented: $chargerLibraryVisibility) {
163
+            ChargedDeviceLibrarySheetView(
164
+                visibility: $chargerLibraryVisibility,
165
+                meterMACAddress: usbMeter.btSerial.macAddress.description,
166
+                meterTint: usbMeter.color,
167
+                mode: .charger
168
+            )
169
+            .environmentObject(appData)
170
+        }
171
+        .sheet(isPresented: $checkpointEditorVisibility) {
172
+            BatteryCheckpointEditorSheetView()
173
+                .environmentObject(appData)
174
+                .environmentObject(usbMeter)
175
+        }
176
+        .sheet(item: $editingChargedDevice) { chargedDevice in
177
+            ChargedDeviceEditorSheetView(
178
+                meterMACAddress: nil,
179
+                chargedDevice: chargedDevice
180
+            )
181
+            .environmentObject(appData)
182
+        }
183
+        .sheet(isPresented: $targetNotificationEditorVisibility) {
184
+            if let activeChargeSession {
185
+                BatteryTargetNotificationEditorSheetView(
186
+                    sessionID: activeChargeSession.id,
187
+                    initialTargetPercent: activeChargeSession.targetBatteryPercent
188
+                )
189
+                .environmentObject(appData)
190
+            }
191
+        }
192
+    }
193
+
194
+    private var selectedChargedDevice: ChargedDeviceSummary? {
195
+        appData.currentChargedDeviceSummary(for: usbMeter.btSerial.macAddress.description)
196
+    }
197
+
198
+    private var activeChargeSession: ChargeSessionSummary? {
199
+        appData.activeChargeSessionSummary(for: usbMeter.btSerial.macAddress.description)
200
+    }
201
+
202
+    private var selectedCharger: ChargedDeviceSummary? {
203
+        appData.currentChargerSummary(for: usbMeter.btSerial.macAddress.description)
204
+    }
205
+
206
+    private var chargedDeviceSection: some View {
207
+        VStack(alignment: .leading, spacing: 12) {
208
+            HStack {
209
+                Text("Device")
210
+                    .font(.headline)
211
+                Spacer()
212
+                Button("Library") {
213
+                    chargedDeviceLibraryVisibility = true
214
+                }
215
+            }
216
+
217
+            if let selectedChargedDevice {
218
+                HStack(alignment: .top, spacing: 14) {
219
+                    ChargedDeviceQRCodeView(
220
+                        qrIdentifier: selectedChargedDevice.qrIdentifier,
221
+                        side: 88
222
+                    )
223
+
224
+                    VStack(alignment: .leading, spacing: 8) {
225
+                        Label(selectedChargedDevice.name, systemImage: selectedChargedDevice.deviceClass.symbolName)
226
+                            .font(.headline)
227
+
228
+                        Text(selectedChargedDevice.deviceClass.title)
229
+                            .font(.caption.weight(.semibold))
230
+                            .foregroundColor(.secondary)
231
+
232
+                        Text(selectedChargedDevice.supportsChargingWhileOff ? "Can finish charging while off" : "Needs on-state sessions to estimate capacity carefully")
233
+                            .font(.caption2)
234
+                            .foregroundColor(.secondary)
235
+
236
+                        if selectedChargedDevice.supportedChargingModes.count == 1 {
237
+                            Label(
238
+                                "Charging via \(selectedChargedDevice.preferredChargingTransportMode.title)",
239
+                                systemImage: selectedChargedDevice.preferredChargingTransportMode.symbolName
240
+                            )
241
+                            .font(.caption2)
242
+                            .foregroundColor(.secondary)
243
+                        } else {
244
+                            Picker("Charging Type", selection: chargingTransportModeBinding(for: selectedChargedDevice)) {
245
+                                ForEach(selectedChargedDevice.supportedChargingModes) { chargingTransportMode in
246
+                                    Label(chargingTransportMode.title, systemImage: chargingTransportMode.symbolName)
247
+                                        .tag(chargingTransportMode)
248
+                                }
249
+                            }
250
+                            .pickerStyle(.segmented)
251
+                        }
252
+
253
+                        if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
254
+                            Text("Estimated \(effectiveChargingTransportMode(for: selectedChargedDevice).title.lowercased()) capacity: \(capacity.format(decimalDigits: 2)) Wh")
255
+                                .font(.caption)
256
+                                .foregroundColor(.secondary)
257
+                        } else if let capacity = selectedChargedDevice.estimatedBatteryCapacityWh {
258
+                            Text("Estimated capacity: \(capacity.format(decimalDigits: 2)) Wh")
259
+                                .font(.caption)
260
+                                .foregroundColor(.secondary)
261
+                        }
262
+
263
+                        if let minimumCurrent = selectedChargedDevice.resolvedCompletionCurrentAmps(for: effectiveChargingTransportMode(for: selectedChargedDevice)) {
264
+                            Text("\(effectiveChargingTransportMode(for: selectedChargedDevice).title) completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
265
+                                .font(.caption2)
266
+                                .foregroundColor(.secondary)
267
+                        } else if let minimumCurrent = selectedChargedDevice.minimumCurrentAmps {
268
+                            Text("Completion current: \(minimumCurrent.format(decimalDigits: 2)) A")
269
+                                .font(.caption2)
270
+                                .foregroundColor(.secondary)
271
+                        }
272
+                    }
273
+
274
+                    Spacer(minLength: 0)
275
+                }
276
+
277
+                if shouldShowWirelessChargerSection(for: selectedChargedDevice) {
278
+                    Divider()
279
+
280
+                    VStack(alignment: .leading, spacing: 10) {
281
+                        HStack {
282
+                            Text("Wireless Charger")
283
+                                .font(.subheadline.weight(.semibold))
284
+                            Spacer()
285
+                            Button(selectedCharger == nil ? "Select" : "Change") {
286
+                                chargerLibraryVisibility = true
287
+                            }
288
+                        }
289
+
290
+                        if let selectedCharger {
291
+                            HStack(alignment: .top, spacing: 12) {
292
+                                ChargedDeviceQRCodeView(
293
+                                    qrIdentifier: selectedCharger.qrIdentifier,
294
+                                    side: 62
295
+                                )
296
+
297
+                                VStack(alignment: .leading, spacing: 6) {
298
+                                    Label(selectedCharger.name, systemImage: selectedCharger.deviceClass.symbolName)
299
+                                        .font(.subheadline.weight(.semibold))
300
+
301
+                                    if let chargerMaximumPowerWatts = selectedCharger.chargerMaximumPowerWatts {
302
+                                        Text("Max power: \(chargerMaximumPowerWatts.format(decimalDigits: 2)) W")
303
+                                            .font(.caption)
304
+                                            .foregroundColor(.secondary)
305
+                                    }
306
+
307
+                                    if !selectedCharger.chargerObservedVoltageSelections.isEmpty {
308
+                                        Text(
309
+                                            "Observed voltages: " + selectedCharger.chargerObservedVoltageSelections
310
+                                                .map { "\($0.format(decimalDigits: 1)) V" }
311
+                                                .joined(separator: ", ")
312
+                                        )
313
+                                        .font(.caption2)
314
+                                        .foregroundColor(.secondary)
315
+                                    }
316
+                                }
317
+                            }
318
+                        } else {
319
+                            Text("Wireless sessions need a selected charger in addition to the charged device.")
320
+                                .font(.caption)
321
+                                .foregroundColor(.secondary)
322
+                        }
323
+                    }
324
+                }
325
+
326
+                Button("Add Battery Checkpoint") {
327
+                    checkpointEditorVisibility = true
328
+                }
329
+                .frame(maxWidth: .infinity)
330
+                .padding(.vertical, 10)
331
+                .meterCard(tint: .green, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
332
+                .buttonStyle(.plain)
333
+
334
+                Button("Edit Device") {
335
+                    editingChargedDevice = selectedChargedDevice
336
+                }
337
+                .frame(maxWidth: .infinity)
338
+                .padding(.vertical, 10)
339
+                .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
340
+                .buttonStyle(.plain)
341
+            } else {
342
+                Text("Select or create the device you are charging. New sessions, checkpoints, QR identity, capacity tracking, and curve learning are all anchored to that device.")
343
+                    .font(.footnote)
344
+                    .foregroundColor(.secondary)
345
+            }
346
+        }
347
+        .padding(18)
348
+        .meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20)
349
+    }
350
+
351
+    private func chargeMonitorSection(_ activeChargeSession: ChargeSessionSummary) -> some View {
352
+        VStack(alignment: .leading, spacing: 12) {
353
+            Text("Charging Monitor")
354
+                .font(.headline)
355
+
356
+            ChargeRecordMetricsTableView(
357
+                labels: ["Source", "Energy", "Charge", "Stop Threshold"],
358
+                values: [
359
+                    activeChargeSession.sourceMode.title,
360
+                    "\(activeChargeSession.measuredEnergyWh.format(decimalDigits: 3)) Wh",
361
+                    "\(activeChargeSession.measuredChargeAh.format(decimalDigits: 3)) Ah",
362
+                    "\(activeChargeSession.stopThresholdAmps.format(decimalDigits: 2)) A"
363
+                ]
364
+            )
365
+
366
+            if let selectedChargedDevice,
367
+               let batteryPrediction = selectedChargedDevice.batteryLevelPrediction(for: activeChargeSession) {
368
+                VStack(alignment: .leading, spacing: 4) {
369
+                    Text("Predicted battery level: \(batteryPrediction.predictedPercent.format(decimalDigits: 0))%")
370
+                        .font(.caption.weight(.semibold))
371
+                    Text(
372
+                        "Anchored to \(batteryPrediction.anchorDescription) at \(batteryPrediction.anchorPercent.format(decimalDigits: 0))% using \(batteryPrediction.estimatedCapacityWh.format(decimalDigits: 2)) Wh estimated capacity."
373
+                    )
374
+                    .font(.caption2)
375
+                    .foregroundColor(.secondary)
376
+                }
377
+            }
378
+
379
+            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
380
+                Text("Target notification: \(targetBatteryPercent.format(decimalDigits: 0))%")
381
+                    .font(.caption.weight(.semibold))
382
+            } else {
383
+                Text("No target battery notification configured.")
384
+                    .font(.caption)
385
+                    .foregroundColor(.secondary)
386
+            }
387
+
388
+            Button(activeChargeSession.targetBatteryPercent == nil ? "Set Target Notification" : "Change Target Notification") {
389
+                targetNotificationEditorVisibility = true
390
+            }
391
+            .frame(maxWidth: .infinity)
392
+            .padding(.vertical, 10)
393
+            .meterCard(tint: .indigo, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
394
+            .buttonStyle(.plain)
395
+
396
+            if activeChargeSession.targetBatteryPercent != nil {
397
+                Button("Clear Target Notification") {
398
+                    _ = appData.setTargetBatteryPercent(nil, for: activeChargeSession.id)
399
+                }
400
+                .frame(maxWidth: .infinity)
401
+                .padding(.vertical, 10)
402
+                .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 14)
403
+                .buttonStyle(.plain)
404
+            }
405
+
406
+            if activeChargeSession.requiresCompletionConfirmation {
407
+                completionConfirmationCard(activeChargeSession)
408
+            }
409
+
410
+            if let capacityEstimateWh = activeChargeSession.capacityEstimateWh {
411
+                Text("Current \(activeChargeSession.chargingTransportMode.title.lowercased()) capacity estimate: \(capacityEstimateWh.format(decimalDigits: 2)) Wh")
412
+                    .font(.caption.weight(.semibold))
413
+            }
414
+
415
+            Label(
416
+                "Session charging type: \(activeChargeSession.chargingTransportMode.title)",
417
+                systemImage: activeChargeSession.chargingTransportMode.symbolName
418
+            )
419
+            .font(.caption)
420
+            .foregroundColor(.secondary)
421
+
422
+            if activeChargeSession.chargingTransportMode == .wireless {
423
+                if let chargerID = activeChargeSession.chargerID,
424
+                   let charger = appData.chargedDeviceSummary(id: chargerID) {
425
+                    Label("Wireless charger: \(charger.name)", systemImage: "bolt.badge.clock")
426
+                        .font(.caption)
427
+                        .foregroundColor(.secondary)
428
+                } else {
429
+                    Text("No wireless charger is currently selected for this session.")
430
+                        .font(.caption)
431
+                        .foregroundColor(.orange)
432
+                }
433
+            }
434
+
435
+            if activeChargeSession.checkpoints.isEmpty == false {
436
+                VStack(alignment: .leading, spacing: 8) {
437
+                    Text("Battery Checkpoints")
438
+                        .font(.subheadline.weight(.semibold))
439
+
440
+                    ForEach(activeChargeSession.checkpoints.suffix(4), id: \.id) { checkpoint in
441
+                        HStack {
442
+                            Text(checkpoint.timestamp.format())
443
+                                .font(.caption2)
444
+                                .foregroundColor(.secondary)
445
+                            Spacer()
446
+                            Text("\(checkpoint.batteryPercent.format(decimalDigits: 0))%")
447
+                                .font(.caption.weight(.semibold))
448
+                            Text("•")
449
+                                .foregroundColor(.secondary)
450
+                            Text("\(checkpoint.measuredEnergyWh.format(decimalDigits: 2)) Wh")
451
+                                .font(.caption2)
452
+                                .foregroundColor(.secondary)
453
+                        }
454
+                    }
455
+                }
456
+            }
457
+
458
+            Text("The monitor prefers the meter's offline counters when available, then blends them with live samples so reconnects do not lose the real transferred energy.")
459
+                .font(.footnote)
460
+                .foregroundColor(.secondary)
461
+        }
462
+        .padding(18)
463
+        .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20)
464
+    }
465
+
466
+    private func completionConfirmationCard(_ activeChargeSession: ChargeSessionSummary) -> some View {
467
+        VStack(alignment: .leading, spacing: 10) {
468
+            Text("Completion Needs Confirmation")
469
+                .font(.subheadline.weight(.semibold))
470
+
471
+            if let contradictionPercent = activeChargeSession.completionContradictionPercent {
472
+                Text("Current dropped below the stop threshold, but the estimated battery level is only \(contradictionPercent.format(decimalDigits: 0))%.")
473
+                    .font(.caption)
474
+                    .foregroundColor(.secondary)
475
+            } else {
476
+                Text("Current dropped below the stop threshold, but the estimated battery level does not match a normal charge end.")
477
+                    .font(.caption)
478
+                    .foregroundColor(.secondary)
479
+            }
480
+
481
+            if let targetBatteryPercent = activeChargeSession.targetBatteryPercent {
482
+                Text("The active target is \(targetBatteryPercent.format(decimalDigits: 0))%.")
483
+                    .font(.caption2)
484
+                    .foregroundColor(.secondary)
485
+            }
486
+
487
+            Button("Finish Session") {
488
+                _ = appData.confirmChargeSessionCompletion(sessionID: activeChargeSession.id)
489
+            }
490
+            .frame(maxWidth: .infinity)
491
+            .padding(.vertical, 10)
492
+            .meterCard(tint: .orange, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
493
+            .buttonStyle(.plain)
494
+
495
+            Button("Keep Monitoring") {
496
+                _ = appData.continueChargeSessionMonitoring(sessionID: activeChargeSession.id)
497
+            }
498
+            .frame(maxWidth: .infinity)
499
+            .padding(.vertical, 10)
500
+            .meterCard(tint: .blue, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
501
+            .buttonStyle(.plain)
502
+        }
503
+        .padding(14)
504
+        .meterCard(tint: .orange, fillOpacity: 0.10, strokeOpacity: 0.16, cornerRadius: 16)
505
+    }
506
+
507
+    private func chargingTransportModeBinding(for chargedDevice: ChargedDeviceSummary) -> Binding<ChargingTransportMode> {
508
+        Binding(
509
+            get: {
510
+                effectiveChargingTransportMode(for: chargedDevice)
511
+            },
512
+            set: { newValue in
513
+                _ = appData.setChargingTransportMode(newValue, for: usbMeter)
514
+            }
515
+        )
516
+    }
517
+
518
+    private func effectiveChargingTransportMode(for chargedDevice: ChargedDeviceSummary) -> ChargingTransportMode {
519
+        activeChargeSession?.chargingTransportMode ?? chargedDevice.preferredChargingTransportMode
520
+    }
521
+
522
+    private func shouldShowWirelessChargerSection(for chargedDevice: ChargedDeviceSummary) -> Bool {
523
+        effectiveChargingTransportMode(for: chargedDevice) == .wireless
141 524
     }
142 525
 }
143 526
 
@@ -146,3 +529,53 @@ struct ChargeRecordSheetView_Previews: PreviewProvider {
146 529
         ChargeRecordSheetView(visibility: .constant(true))
147 530
     }
148 531
 }
532
+
533
+private struct BatteryTargetNotificationEditorSheetView: View {
534
+    @Environment(\.dismiss) private var dismiss
535
+    @EnvironmentObject private var appData: AppData
536
+
537
+    let sessionID: UUID
538
+    let initialTargetPercent: Double?
539
+
540
+    @State private var targetPercent: Double
541
+
542
+    init(sessionID: UUID, initialTargetPercent: Double?) {
543
+        self.sessionID = sessionID
544
+        self.initialTargetPercent = initialTargetPercent
545
+        _targetPercent = State(initialValue: initialTargetPercent ?? 80)
546
+    }
547
+
548
+    var body: some View {
549
+        NavigationView {
550
+            Form {
551
+                Section(header: Text("Target Level")) {
552
+                    VStack(alignment: .leading, spacing: 12) {
553
+                        Text("\(targetPercent.format(decimalDigits: 0))%")
554
+                            .font(.title3.weight(.bold))
555
+                        Slider(value: $targetPercent, in: 20...100, step: 1)
556
+                        Text("A local notification will be generated on synced devices when the estimated battery level reaches this target.")
557
+                            .font(.footnote)
558
+                            .foregroundColor(.secondary)
559
+                    }
560
+                }
561
+            }
562
+            .navigationTitle("Battery Target")
563
+            .navigationBarTitleDisplayMode(.inline)
564
+            .toolbar {
565
+                ToolbarItem(placement: .cancellationAction) {
566
+                    Button("Cancel") {
567
+                        dismiss()
568
+                    }
569
+                }
570
+
571
+                ToolbarItem(placement: .confirmationAction) {
572
+                    Button("Save") {
573
+                        if appData.setTargetBatteryPercent(targetPercent, for: sessionID) {
574
+                            dismiss()
575
+                        }
576
+                    }
577
+                }
578
+            }
579
+        }
580
+    }
581
+}
+215 -2
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -11,6 +11,7 @@ import SwiftUI
11 11
 struct MeterLiveContentView: View {
12 12
     @EnvironmentObject private var meter: Meter
13 13
     @State private var powerAverageSheetVisibility = false
14
+    @State private var energyProjectionSheetVisibility = false
14 15
     @State private var rssiHistorySheetVisibility = false
15 16
     var compactLayout: Bool = false
16 17
     var availableSize: CGSize? = nil
@@ -82,11 +83,14 @@ struct MeterLiveContentView: View {
82 83
 
83 84
                 if shouldShowEnergyCard {
84 85
                     liveMetricCard(
85
-                        title: "Energy",
86
+                        title: "Accumulated Energy",
86 87
                         symbol: "battery.100.bolt",
87 88
                         color: .teal,
88 89
                         value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh",
89
-                        detailText: "Buffered accumulated energy"
90
+                        detailText: "Tap for monthly and yearly projections",
91
+                        action: {
92
+                            energyProjectionSheetVisibility = true
93
+                        }
90 94
                     )
91 95
                 }
92 96
 
@@ -150,6 +154,11 @@ struct MeterLiveContentView: View {
150 154
             PowerAverageSheetView(visibility: $powerAverageSheetVisibility)
151 155
                 .environmentObject(meter.measurements)
152 156
         }
157
+        .sheet(isPresented: $energyProjectionSheetVisibility) {
158
+            EnergyProjectionSheetView(visibility: $energyProjectionSheetVisibility)
159
+                .environmentObject(meter.measurements)
160
+                .environmentObject(meter)
161
+        }
153 162
         .sheet(isPresented: $rssiHistorySheetVisibility) {
154 163
             RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility)
155 164
                 .environmentObject(meter.measurements)
@@ -690,3 +699,207 @@ private struct RSSIHistorySheetView: View {
690 699
         }
691 700
     }
692 701
 }
702
+
703
+private struct EnergyProjectionSheetView: View {
704
+    @EnvironmentObject private var measurements: Measurements
705
+    @EnvironmentObject private var meter: Meter
706
+
707
+    @Binding var visibility: Bool
708
+    @State private var selectedProjectionMethodID: String = ""
709
+
710
+    var body: some View {
711
+        let snapshot = measurements.energyProjectionSnapshot()
712
+        let projectionVariants = measurements.energyProjectionVariants()
713
+        let projectionVariantIDs = projectionVariants.map(\.id)
714
+        let selectedProjectionVariant = resolvedProjectionVariant(from: projectionVariants)
715
+
716
+        NavigationView {
717
+            ScrollView {
718
+                VStack(alignment: .leading, spacing: 14) {
719
+                    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)
725
+                    }
726
+                    .padding(18)
727
+                    .meterCard(tint: .teal, fillOpacity: 0.18, strokeOpacity: 0.24)
728
+
729
+                    MeterInfoCardView(title: "Current Session", tint: meter.color) {
730
+                        if let snapshot {
731
+                            MeterInfoRowView(
732
+                                label: "Accumulated Energy",
733
+                                value: "\(snapshot.accumulatedEnergy.format(decimalDigits: 3)) Wh"
734
+                            )
735
+                            MeterInfoRowView(
736
+                                label: "Observed Interval",
737
+                                value: observedIntervalText(snapshot.observedDuration)
738
+                            )
739
+                            MeterInfoRowView(
740
+                                label: "Buffered Samples",
741
+                                value: "\(snapshot.sampleCount)"
742
+                            )
743
+                            MeterInfoRowView(
744
+                                label: "Average Power",
745
+                                value: averagePowerText(snapshot.averagePower)
746
+                            )
747
+                        } else {
748
+                            Text("Not enough live energy data yet. Keep the meter connected for a little longer, then reopen this view.")
749
+                                .font(.footnote)
750
+                                .foregroundColor(.secondary)
751
+                        }
752
+                    }
753
+
754
+                    MeterInfoCardView(title: "Projection Method", tint: .teal) {
755
+                        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.")
757
+                                .font(.footnote)
758
+                                .foregroundColor(.secondary)
759
+                        } else {
760
+                            VStack(alignment: .leading, spacing: 14) {
761
+                                Picker("Projection Method", selection: selectedProjectionMethodBinding(for: projectionVariants)) {
762
+                                    ForEach(projectionVariants) { variant in
763
+                                        Text(variant.title).tag(variant.id)
764
+                                    }
765
+                                }
766
+                                .pickerStyle(.menu)
767
+
768
+                                if let selectedProjectionVariant {
769
+                                    projectionVariantView(selectedProjectionVariant)
770
+                                }
771
+                            }
772
+                        }
773
+                    }
774
+                }
775
+                .padding()
776
+                .padding(.top, 8)
777
+            }
778
+            .background(
779
+                LinearGradient(
780
+                    colors: [.teal.opacity(0.14), Color.clear],
781
+                    startPoint: .topLeading,
782
+                    endPoint: .bottomTrailing
783
+                )
784
+                .ignoresSafeArea()
785
+            )
786
+            .navigationTitle("Energy")
787
+            .navigationBarTitleDisplayMode(.inline)
788
+            .toolbar {
789
+                ToolbarItem(placement: .cancellationAction) {
790
+                    Button("Done") { visibility.toggle() }
791
+                }
792
+            }
793
+        }
794
+        .navigationViewStyle(StackNavigationViewStyle())
795
+        .onAppear {
796
+            updateSelectedProjectionMethod(with: projectionVariants)
797
+        }
798
+        .onChange(of: projectionVariantIDs) { _ in
799
+            updateSelectedProjectionMethod(with: projectionVariants)
800
+        }
801
+    }
802
+
803
+    private func projectionRow(title: String, value: String) -> some View {
804
+        MeterInfoRowView(label: title, value: value)
805
+    }
806
+
807
+    private func projectionVariantView(_ variant: Measurements.EnergyProjectionVariant) -> some View {
808
+        VStack(alignment: .leading, spacing: 8) {
809
+            Text(variant.title)
810
+                .font(.subheadline.weight(.semibold))
811
+
812
+            projectionRow(title: "Observed Interval", value: observedIntervalText(variant.observedDuration))
813
+            projectionRow(title: "Window Energy", value: energyText(variant.accumulatedEnergy))
814
+            projectionRow(title: "Average Power", value: averagePowerText(variant.averagePower))
815
+            projectionRow(title: "Monthly", value: projectedEnergyText(variant.projectedMonthlyEnergy))
816
+            projectionRow(title: "Yearly", value: projectedEnergyText(variant.projectedYearlyEnergy))
817
+        }
818
+        .padding(.bottom, 2)
819
+    }
820
+
821
+    private func resolvedProjectionVariant(from variants: [Measurements.EnergyProjectionVariant]) -> Measurements.EnergyProjectionVariant? {
822
+        if let selectedVariant = variants.first(where: { $0.id == selectedProjectionMethodID }) {
823
+            return selectedVariant
824
+        }
825
+
826
+        return variants.last
827
+    }
828
+
829
+    private func selectedProjectionMethodBinding(
830
+        for variants: [Measurements.EnergyProjectionVariant]
831
+    ) -> Binding<String> {
832
+        Binding(
833
+            get: {
834
+                resolvedProjectionVariant(from: variants)?.id ?? ""
835
+            },
836
+            set: { newValue in
837
+                selectedProjectionMethodID = newValue
838
+            }
839
+        )
840
+    }
841
+
842
+    private func updateSelectedProjectionMethod(with variants: [Measurements.EnergyProjectionVariant]) {
843
+        guard !variants.isEmpty else {
844
+            selectedProjectionMethodID = ""
845
+            return
846
+        }
847
+
848
+        if variants.contains(where: { $0.id == selectedProjectionMethodID }) {
849
+            return
850
+        }
851
+
852
+        selectedProjectionMethodID = variants.last?.id ?? ""
853
+    }
854
+
855
+    private func observedIntervalText(_ duration: TimeInterval) -> String {
856
+        guard duration > 0 else { return "Insufficient data" }
857
+
858
+        let totalSeconds = Int(duration.rounded())
859
+        let hours = totalSeconds / 3600
860
+        let minutes = (totalSeconds % 3600) / 60
861
+        let seconds = totalSeconds % 60
862
+
863
+        if hours > 0 {
864
+            return "\(hours)h \(minutes)m"
865
+        }
866
+
867
+        if minutes > 0 {
868
+            return "\(minutes)m \(seconds)s"
869
+        }
870
+
871
+        return "\(seconds)s"
872
+    }
873
+
874
+    private func averagePowerText(_ averagePower: Double?) -> String {
875
+        guard let averagePower, averagePower.isFinite else {
876
+            return "Insufficient data"
877
+        }
878
+
879
+        return "\(averagePower.format(decimalDigits: 3)) W"
880
+    }
881
+
882
+    private func averagePowerText(_ averagePower: Double) -> String {
883
+        averagePowerText(Optional(averagePower))
884
+    }
885
+
886
+    private func energyText(_ energy: Double) -> String {
887
+        if energy >= 1000 {
888
+            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
889
+        }
890
+
891
+        return "\(energy.format(decimalDigits: 3)) Wh"
892
+    }
893
+
894
+    private func projectedEnergyText(_ energy: Double?) -> String {
895
+        guard let energy, energy.isFinite else {
896
+            return "Insufficient data"
897
+        }
898
+
899
+        if energy >= 1000 {
900
+            return "\((energy / 1000).format(decimalDigits: 3)) kWh"
901
+        }
902
+
903
+        return "\(energy.format(decimalDigits: 1)) Wh"
904
+    }
905
+}
+28 -2
USB Meter/Views/Meter/Tabs/Settings/MeterSettingsTabView.swift
@@ -6,6 +6,7 @@
6 6
 import SwiftUI
7 7
 
8 8
 struct MeterSettingsTabView: View {
9
+    @EnvironmentObject private var appData: AppData
9 10
     @EnvironmentObject private var meter: Meter
10 11
 
11 12
     let isMacIPadApp: Bool
@@ -14,6 +15,7 @@ struct MeterSettingsTabView: View {
14 15
     @State private var editingName = false
15 16
     @State private var editingScreenTimeout = false
16 17
     @State private var editingScreenBrightness = false
18
+    @State private var deleteConfirmationVisibility = false
17 19
 
18 20
     var body: some View {
19 21
         VStack(spacing: 0) {
@@ -107,6 +109,20 @@ struct MeterSettingsTabView: View {
107 109
                             }
108 110
                         }
109 111
                     }
112
+
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
+
118
+                        Button("Delete Meter") {
119
+                            deleteConfirmationVisibility = true
120
+                        }
121
+                        .frame(maxWidth: .infinity)
122
+                        .padding(.vertical, 10)
123
+                        .meterCard(tint: .red, fillOpacity: 0.16, strokeOpacity: 0.22, cornerRadius: 14)
124
+                        .buttonStyle(.plain)
125
+                    }
110 126
                 }
111 127
                 .padding()
112 128
             }
@@ -115,11 +131,21 @@ struct MeterSettingsTabView: View {
115 131
                     colors: [meter.color.opacity(0.14), Color.clear],
116 132
                     startPoint: .topLeading,
117 133
                     endPoint: .bottomTrailing
118
-                )
119
-                .ignoresSafeArea()
120 134
             )
135
+            .ignoresSafeArea()
136
+        )
137
+        .alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) {
138
+            Button("Delete", role: .destructive) {
139
+                if appData.deleteMeter(macAddress: meter.btSerial.macAddress.description) {
140
+                    onBackToHome()
141
+                }
142
+            }
143
+            Button("Cancel", role: .cancel) {}
144
+        } message: {
145
+            Text("This removes the saved meter entry and disconnects the live meter view.")
121 146
         }
122 147
     }
148
+    }
123 149
 
124 150
     private var settingsMacHeader: some View {
125 151
         HStack(spacing: 12) {
+196 -0
USB Meter/Views/MeterDetailView.swift
@@ -1,6 +1,11 @@
1 1
 import SwiftUI
2 2
 
3 3
 struct MeterDetailView: View {
4
+    @EnvironmentObject private var appData: AppData
5
+    @Environment(\.dismiss) private var dismiss
6
+    @State private var editorVisibility = false
7
+    @State private var deleteConfirmationVisibility = false
8
+
4 9
     let meterSummary: AppData.MeterSummary
5 10
 
6 11
     var body: some View {
@@ -9,6 +14,8 @@ struct MeterDetailView: View {
9 14
                 headerCard
10 15
                 statusCard
11 16
                 identifiersCard
17
+                chargedDevicesCard
18
+                chargersCard
12 19
             }
13 20
             .padding()
14 21
         }
@@ -21,6 +28,32 @@ struct MeterDetailView: View {
21 28
             .ignoresSafeArea()
22 29
         )
23 30
         .navigationTitle(meterSummary.displayName)
31
+        .toolbar {
32
+            ToolbarItemGroup(placement: .primaryAction) {
33
+                Button("Edit") {
34
+                    editorVisibility = true
35
+                }
36
+                Button(role: .destructive) {
37
+                    deleteConfirmationVisibility = true
38
+                } label: {
39
+                    Image(systemName: "trash")
40
+                }
41
+            }
42
+        }
43
+        .sheet(isPresented: $editorVisibility) {
44
+            MeterEditorSheetView(existingMeterSummary: meterSummary)
45
+                .environmentObject(appData)
46
+        }
47
+        .alert("Delete Meter?", isPresented: $deleteConfirmationVisibility) {
48
+            Button("Delete", role: .destructive) {
49
+                if appData.deleteMeter(macAddress: meterSummary.macAddress) {
50
+                    dismiss()
51
+                }
52
+            }
53
+            Button("Cancel", role: .cancel) {}
54
+        } message: {
55
+            Text("This removes the stored meter entry and its saved metadata from the sidebar until the meter is discovered again.")
56
+        }
24 57
     }
25 58
 
26 59
     private var headerCard: some View {
@@ -76,6 +109,80 @@ struct MeterDetailView: View {
76 109
         .meterCard(tint: .secondary, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
77 110
     }
78 111
 
112
+    private var chargedDevicesCard: some View {
113
+        let chargedDevices = appData.chargedDevices(for: meterSummary.macAddress)
114
+
115
+        return VStack(alignment: .leading, spacing: 10) {
116
+            Text("Devices")
117
+                .font(.headline)
118
+
119
+            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.")
121
+                    .font(.caption)
122
+                    .foregroundColor(.secondary)
123
+            } else {
124
+                ForEach(chargedDevices.prefix(3)) { chargedDevice in
125
+                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: chargedDevice.id)) {
126
+                        HStack(spacing: 12) {
127
+                            ChargedDeviceQRCodeView(qrIdentifier: chargedDevice.qrIdentifier, side: 52)
128
+
129
+                            VStack(alignment: .leading, spacing: 4) {
130
+                                Label(chargedDevice.name, systemImage: chargedDevice.deviceClass.symbolName)
131
+                                    .font(.subheadline.weight(.semibold))
132
+                                Text(chargedDevice.estimatedBatteryCapacityWh.map { "\($0.format(decimalDigits: 2)) Wh" } ?? "Capacity: learning")
133
+                                    .font(.caption)
134
+                                    .foregroundColor(.secondary)
135
+                            }
136
+
137
+                            Spacer()
138
+                        }
139
+                    }
140
+                    .buttonStyle(.plain)
141
+                }
142
+            }
143
+        }
144
+        .frame(maxWidth: .infinity, alignment: .leading)
145
+        .padding(18)
146
+        .meterCard(tint: .orange, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
147
+    }
148
+
149
+    private var chargersCard: some View {
150
+        let chargers = appData.chargers(for: meterSummary.macAddress)
151
+
152
+        return VStack(alignment: .leading, spacing: 10) {
153
+            Text("Chargers")
154
+                .font(.headline)
155
+
156
+            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.")
158
+                    .font(.caption)
159
+                    .foregroundColor(.secondary)
160
+            } else {
161
+                ForEach(chargers.prefix(3)) { charger in
162
+                    NavigationLink(destination: ChargedDeviceDetailView(chargedDeviceID: charger.id)) {
163
+                        HStack(spacing: 12) {
164
+                            ChargedDeviceQRCodeView(qrIdentifier: charger.qrIdentifier, side: 52)
165
+
166
+                            VStack(alignment: .leading, spacing: 4) {
167
+                                Label(charger.name, systemImage: charger.deviceClass.symbolName)
168
+                                    .font(.subheadline.weight(.semibold))
169
+                                Text(charger.chargerMaximumPowerWatts.map { "Max power: \($0.format(decimalDigits: 2)) W" } ?? "Wireless charger")
170
+                                    .font(.caption)
171
+                                    .foregroundColor(.secondary)
172
+                            }
173
+
174
+                            Spacer()
175
+                        }
176
+                    }
177
+                    .buttonStyle(.plain)
178
+                }
179
+            }
180
+        }
181
+        .frame(maxWidth: .infinity, alignment: .leading)
182
+        .padding(18)
183
+        .meterCard(tint: .pink, fillOpacity: 0.12, strokeOpacity: 0.18, cornerRadius: 18)
184
+    }
185
+
79 186
     private func infoRow(label: String, value: String) -> some View {
80 187
         HStack {
81 188
             Text(label)
@@ -100,5 +207,94 @@ struct MeterDetailView_Previews: PreviewProvider {
100 207
                 meter: nil
101 208
             )
102 209
         )
210
+        .environmentObject(appData)
211
+    }
212
+}
213
+
214
+struct MeterEditorSheetView: View {
215
+    @EnvironmentObject private var appData: AppData
216
+    @Environment(\.dismiss) private var dismiss
217
+
218
+    let existingMeterSummary: AppData.MeterSummary?
219
+
220
+    @State private var customName: String
221
+    @State private var macAddress: String
222
+    @State private var advertisedName: String
223
+    @State private var selectedModel: Model
224
+
225
+    init(existingMeterSummary: AppData.MeterSummary? = nil) {
226
+        self.existingMeterSummary = existingMeterSummary
227
+        _customName = State(initialValue: existingMeterSummary?.displayName ?? "")
228
+        _macAddress = State(initialValue: existingMeterSummary?.macAddress ?? "")
229
+        _advertisedName = State(initialValue: existingMeterSummary?.advertisedName ?? "")
230
+        _selectedModel = State(initialValue: Self.model(for: existingMeterSummary?.modelSummary))
231
+    }
232
+
233
+    var body: some View {
234
+        NavigationView {
235
+            Form {
236
+                Section(header: Text("Identity")) {
237
+                    TextField("Display name", text: $customName)
238
+                    TextField("MAC Address", text: $macAddress)
239
+                        .textInputAutocapitalization(.characters)
240
+                        .disableAutocorrection(true)
241
+                        .disabled(existingMeterSummary != nil)
242
+
243
+                    Picker("Model", selection: $selectedModel) {
244
+                        ForEach(Model.allCases, id: \.self) { model in
245
+                            Text(model.canonicalName)
246
+                                .tag(model)
247
+                        }
248
+                    }
249
+
250
+                    TextField("Advertised name", text: $advertisedName)
251
+                }
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
+            }
259
+            .navigationTitle(existingMeterSummary == nil ? "New Meter" : "Edit Meter")
260
+            .navigationBarTitleDisplayMode(.inline)
261
+            .toolbar {
262
+                ToolbarItem(placement: .cancellationAction) {
263
+                    Button("Cancel") {
264
+                        dismiss()
265
+                    }
266
+                }
267
+                ToolbarItem(placement: .confirmationAction) {
268
+                    Button(existingMeterSummary == nil ? "Save" : "Update") {
269
+                        let normalizedMAC = AppData.normalizedMACAddress(macAddress)
270
+                        let didSave = appData.createKnownMeter(
271
+                            macAddress: normalizedMAC,
272
+                            customName: customName,
273
+                            modelName: selectedModel.canonicalName,
274
+                            advertisedName: advertisedName
275
+                        )
276
+                        if didSave {
277
+                            dismiss()
278
+                        }
279
+                    }
280
+                    .disabled(isSaveDisabled)
281
+                }
282
+            }
283
+        }
284
+        .navigationViewStyle(StackNavigationViewStyle())
285
+    }
286
+
287
+    private var isSaveDisabled: Bool {
288
+        AppData.isValidMACAddress(AppData.normalizedMACAddress(macAddress)) == false
289
+    }
290
+
291
+    private static func model(for summary: String?) -> Model {
292
+        if summary?.contains("UM34C") == true {
293
+            return .UM34C
294
+        }
295
+        if summary?.contains("TC66C") == true {
296
+            return .TC66C
297
+        }
298
+        return .UM25C
103 299
     }
104 300
 }
+7 -0
USB Meter/Views/Sidebar/SidebarList/Sections/SidebarUSBMetersSectionView.swift
@@ -13,6 +13,7 @@ struct SidebarUSBMetersSectionView: View {
13 13
     let scanStartedAt: Date?
14 14
     let now: Date
15 15
     let noDevicesHelpDelay: TimeInterval
16
+    let onAddMeter: () -> Void
16 17
 
17 18
     var body: some View {
18 19
         Section(header: usbSectionHeader) {
@@ -79,6 +80,12 @@ struct SidebarUSBMetersSectionView: View {
79 80
                 }
80 81
             }
81 82
             Spacer()
83
+            Button(action: onAddMeter) {
84
+                Image(systemName: "plus.circle.fill")
85
+                    .font(.body.weight(.semibold))
86
+                    .foregroundColor(.blue)
87
+            }
88
+            .buttonStyle(.plain)
82 89
             Text("\(meters.count)")
83 90
                 .font(.caption.weight(.bold))
84 91
                 .padding(.horizontal, 10)
+64 -8
USB Meter/Views/Sidebar/SidebarView.swift
@@ -6,11 +6,29 @@
6 6
 import SwiftUI
7 7
 import Combine
8 8
 
9
+private enum SidebarCreationSheet: Identifiable {
10
+    case meter
11
+    case device
12
+    case charger
13
+
14
+    var id: String {
15
+        switch self {
16
+        case .meter:
17
+            return "meter"
18
+        case .device:
19
+            return "device"
20
+        case .charger:
21
+            return "charger"
22
+        }
23
+    }
24
+}
25
+
9 26
 struct SidebarView: View {
10 27
     @EnvironmentObject private var appData: AppData
11 28
     @State private var isHelpExpanded = false
12 29
     @State private var dismissedAutoHelpReason: SidebarHelpReason?
13 30
     @State private var now = Date()
31
+    @State private var creationSheet: SidebarCreationSheet?
14 32
     private let helpRefreshTimer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
15 33
     private let noDevicesHelpDelay: TimeInterval = 12
16 34
 
@@ -34,17 +52,55 @@ struct SidebarView: View {
34 52
                 dismissedAutoHelpReason = nil
35 53
             }
36 54
         }
55
+        .sheet(item: $creationSheet) { sheet in
56
+            switch sheet {
57
+            case .meter:
58
+                MeterEditorSheetView()
59
+                    .environmentObject(appData)
60
+            case .device:
61
+                ChargedDeviceEditorSheetView(
62
+                    meterMACAddress: nil,
63
+                    suggestedDeviceClass: .iphone
64
+                )
65
+                .environmentObject(appData)
66
+            case .charger:
67
+                ChargedDeviceEditorSheetView(
68
+                    meterMACAddress: nil,
69
+                    suggestedDeviceClass: .charger
70
+                )
71
+                .environmentObject(appData)
72
+            }
73
+        }
37 74
     }
38 75
 
39 76
     private var usbMetersSection: some View {
40
-        SidebarUSBMetersSectionView(
41
-            meters: appData.meterSummaries,
42
-            managerState: appData.bluetoothManager.managerState,
43
-            hasLiveMeters: appData.meters.isEmpty == false,
44
-            scanStartedAt: appData.bluetoothManager.scanStartedAt,
45
-            now: now,
46
-            noDevicesHelpDelay: noDevicesHelpDelay
47
-        )
77
+        Group {
78
+            SidebarUSBMetersSectionView(
79
+                meters: appData.meterSummaries,
80
+                managerState: appData.bluetoothManager.managerState,
81
+                hasLiveMeters: appData.meters.isEmpty == false,
82
+                scanStartedAt: appData.bluetoothManager.scanStartedAt,
83
+                now: now,
84
+                noDevicesHelpDelay: noDevicesHelpDelay,
85
+                onAddMeter: { creationSheet = .meter }
86
+            )
87
+
88
+            SidebarChargedDevicesSectionView(
89
+                title: "Devices",
90
+                chargedDevices: appData.deviceSummaries,
91
+                emptyStateText: "No devices yet. Open Charge Record on a live meter or use the add button here to create one and start learning capacity.",
92
+                tint: .orange,
93
+                onAdd: { creationSheet = .device }
94
+            )
95
+
96
+            SidebarChargedDevicesSectionView(
97
+                title: "Chargers",
98
+                chargedDevices: appData.chargerSummaries,
99
+                emptyStateText: "No chargers yet. Add one here so wireless sessions can track both the charged device and the charger being used.",
100
+                tint: .pink,
101
+                onAdd: { creationSheet = .charger }
102
+            )
103
+        }
48 104
     }
49 105
 
50 106
     private var helpSection: some View {