@@ -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; |
@@ -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 |
} |
@@ -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> |
@@ -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 |
} |
@@ -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 |
} |
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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> |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
} |
@@ -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 {
|
@@ -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 |
} |
@@ -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 |
|
@@ -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> |
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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 |
+} |
|
@@ -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) {
|
@@ -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 |
} |
@@ -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) |
@@ -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 {
|