- Add ConsumptionMonitorStore and ConsumptionMonitorView (new files) - Add startConsumptionMonitor / stopConsumptionMonitor / deleteConsumptionSession to AppData - Feed live meter observations into active consumption sessions - Add startPowerbankChargeSession to AppData - Extend MeterChargeRecordTabView with consumption monitor mode and powerbank draft state - Update ChargeSessionDetailView and BatteryCheckpointEditorSheetView accordingly - Wire consumptionMonitorStoreDidChange notification to reload charged devices
@@ -54,6 +54,8 @@ |
||
| 54 | 54 |
AAD5F9B12B1CAC7A00F8E4F9 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */; };
|
| 55 | 55 |
B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */; };
|
| 56 | 56 |
B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */; };
|
| 57 |
+ B0A000133C8F000100A10013 /* ConsumptionMonitorStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000033C8F000100A10003 /* ConsumptionMonitorStore.swift */; };
|
|
| 58 |
+ B0A000143C8F000100A10014 /* ConsumptionMonitorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A000043C8F000100A10004 /* ConsumptionMonitorView.swift */; };
|
|
| 57 | 59 |
C10000013C8E4A7A00A10001 /* ChargeInsightsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */; };
|
| 58 | 60 |
C10000023C8E4A7A00A10002 /* ChargeInsightsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */; };
|
| 59 | 61 |
C10000033C8E4A7A00A10003 /* ChargedDeviceQRCodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */; };
|
@@ -184,6 +186,8 @@ |
||
| 184 | 186 |
AAD5F9B22B1CAC7A00F8E4F9 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
|
| 185 | 187 |
B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerStore.swift; sourceTree = "<group>"; };
|
| 186 | 188 |
B0A000023C8F000100A10002 /* ChargerStandbyPowerWizardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargerStandbyPowerWizardView.swift; sourceTree = "<group>"; };
|
| 189 |
+ B0A000033C8F000100A10003 /* ConsumptionMonitorStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsumptionMonitorStore.swift; sourceTree = "<group>"; };
|
|
| 190 |
+ B0A000043C8F000100A10004 /* ConsumptionMonitorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsumptionMonitorView.swift; sourceTree = "<group>"; };
|
|
| 187 | 191 |
C10000113C8E4A7A00A10011 /* ChargeInsightsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsModel.swift; sourceTree = "<group>"; };
|
| 188 | 192 |
C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargeInsightsStore.swift; sourceTree = "<group>"; };
|
| 189 | 193 |
C10000133C8E4A7A00A10013 /* ChargedDeviceQRCodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChargedDeviceQRCodeView.swift; sourceTree = "<group>"; };
|
@@ -478,6 +482,7 @@ |
||
| 478 | 482 |
C10000123C8E4A7A00A10012 /* ChargeInsightsStore.swift */, |
| 479 | 483 |
F20000016F5D4C95B6487F16 /* ChargingWindowDetector.swift */, |
| 480 | 484 |
B0A000013C8F000100A10001 /* ChargerStandbyPowerStore.swift */, |
| 485 |
+ B0A000033C8F000100A10003 /* ConsumptionMonitorStore.swift */, |
|
| 481 | 486 |
C10000213C8E4A7A00A10021 /* CKModel.xcdatamodeld */, |
| 482 | 487 |
7396C8BB36F4E7F8E0CD8FF8 /* MeterNameStore.swift */, |
| 483 | 488 |
43CBF676240C043E00255B8B /* BluetoothManager.swift */, |
@@ -551,6 +556,7 @@ |
||
| 551 | 556 |
C10000203C8E4A7A00A10020 /* ChargedDevices */ = {
|
| 552 | 557 |
isa = PBXGroup; |
| 553 | 558 |
children = ( |
| 559 |
+ B0A000043C8F000100A10004 /* ConsumptionMonitorView.swift */, |
|
| 554 | 560 |
CD0000103FA0000000000010 /* Components */, |
| 555 | 561 |
CD0000113FA0000000000011 /* Details */, |
| 556 | 562 |
CD0000123FA0000000000012 /* Sessions */, |
@@ -936,6 +942,8 @@ |
||
| 936 | 942 |
C10000093C8E4A7A00A10009 /* CKModel.xcdatamodeld in Sources */, |
| 937 | 943 |
B0A000113C8F000100A10011 /* ChargerStandbyPowerStore.swift in Sources */, |
| 938 | 944 |
B0A000123C8F000100A10012 /* ChargerStandbyPowerWizardView.swift in Sources */, |
| 945 |
+ B0A000133C8F000100A10013 /* ConsumptionMonitorStore.swift in Sources */, |
|
| 946 |
+ B0A000143C8F000100A10014 /* ConsumptionMonitorView.swift in Sources */, |
|
| 939 | 947 |
4360A34D241CBB3800B464F9 /* RSSIView.swift in Sources */, |
| 940 | 948 |
430CB4FD245E07EB006525C3 /* AdaptiveTabBarPresentation.swift in Sources */, |
| 941 | 949 |
3DB80493A78F47DB8613585C /* SidebarToggleToolbar.swift in Sources */, |
@@ -42,6 +42,7 @@ final class AppData : ObservableObject {
|
||
| 42 | 42 |
private var chargeInsightsStoreObserver: AnyCancellable? |
| 43 | 43 |
private var chargeInsightsRemoteObserver: AnyCancellable? |
| 44 | 44 |
private var chargerStandbyPowerStoreObserver: AnyCancellable? |
| 45 |
+ private var consumptionMonitorStoreObserver: AnyCancellable? |
|
| 45 | 46 |
private var pendingChargedDevicesReloadWorkItem: DispatchWorkItem? |
| 46 | 47 |
private var chargeInsightsReadStore: ChargeInsightsStore? |
| 47 | 48 |
private var pendingChargeObservationSnapshots: [String: ChargingMonitorSnapshot] = [:] |
@@ -57,6 +58,7 @@ final class AppData : ObservableObject {
|
||
| 57 | 58 |
private let meterStore = MeterNameStore.shared |
| 58 | 59 |
private var chargeInsightsStore: ChargeInsightsStore? |
| 59 | 60 |
private let chargerStandbyPowerStore = ChargerStandbyPowerStore() |
| 61 |
+ private let consumptionMonitorStore = ConsumptionMonitorStore() |
|
| 60 | 62 |
private let chargeNotificationCoordinator = ChargeNotificationCoordinator() |
| 61 | 63 |
private var meterSummariesCache: (version: Int, summaries: [MeterSummary])? |
| 62 | 64 |
private var meterSummariesVersion: Int = 0 |
@@ -82,6 +84,11 @@ final class AppData : ObservableObject {
|
||
| 82 | 84 |
.sink { [weak self] _ in
|
| 83 | 85 |
self?.reloadChargedDevices() |
| 84 | 86 |
} |
| 87 |
+ consumptionMonitorStoreObserver = NotificationCenter.default.publisher(for: .consumptionMonitorStoreDidChange) |
|
| 88 |
+ .receive(on: DispatchQueue.main) |
|
| 89 |
+ .sink { [weak self] _ in
|
|
| 90 |
+ self?.reloadChargedDevices() |
|
| 91 |
+ } |
|
| 85 | 92 |
} |
| 86 | 93 |
|
| 87 | 94 |
let bluetoothManager = BluetoothManager() |
@@ -96,6 +103,7 @@ final class AppData : ObservableObject {
|
||
| 96 | 103 |
@Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] |
| 97 | 104 |
@Published private(set) var powerbanks: [PowerbankSummary] = [] |
| 98 | 105 |
@Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:] |
| 106 |
+ @Published private(set) var activeConsumptionSessions: [String: ConsumptionMonitorLiveSession] = [:] |
|
| 99 | 107 |
|
| 100 | 108 |
var deviceSummaries: [ChargedDeviceSummary] {
|
| 101 | 109 |
chargedDevices.filter { !$0.isCharger }
|
@@ -254,6 +262,11 @@ final class AppData : ObservableObject {
|
||
| 254 | 262 |
return session |
| 255 | 263 |
} |
| 256 | 264 |
} |
| 265 |
+ for powerbank in powerbanks {
|
|
| 266 |
+ if let session = (powerbank.sessionsAsSubject + powerbank.sessionsAsSource).first(where: { $0.id == id }) {
|
|
| 267 |
+ return session |
|
| 268 |
+ } |
|
| 269 |
+ } |
|
| 257 | 270 |
return nil |
| 258 | 271 |
} |
| 259 | 272 |
|
@@ -373,6 +386,88 @@ final class AppData : ObservableObject {
|
||
| 373 | 386 |
return didDelete |
| 374 | 387 |
} |
| 375 | 388 |
|
| 389 |
+ // MARK: - Consumption Monitor |
|
| 390 |
+ |
|
| 391 |
+ func consumptionMonitorSession(for meterMACAddress: String) -> ConsumptionMonitorLiveSession? {
|
|
| 392 |
+ activeConsumptionSessions[Self.normalizedMACAddress(meterMACAddress)] |
|
| 393 |
+ } |
|
| 394 |
+ |
|
| 395 |
+ @discardableResult |
|
| 396 |
+ func startConsumptionMonitor(for deviceID: UUID, on meter: Meter) -> Bool {
|
|
| 397 |
+ let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description) |
|
| 398 |
+ if let existing = activeConsumptionSessions[normalizedMAC] {
|
|
| 399 |
+ return existing.chargedDeviceID == deviceID |
|
| 400 |
+ } |
|
| 401 |
+ |
|
| 402 |
+ let sessionID = UUID() |
|
| 403 |
+ let now = Date() |
|
| 404 |
+ let session = ConsumptionMonitorLiveSession( |
|
| 405 |
+ sessionID: sessionID, |
|
| 406 |
+ chargedDeviceID: deviceID, |
|
| 407 |
+ meterMACAddress: meter.btSerial.macAddress.description, |
|
| 408 |
+ startedAt: now |
|
| 409 |
+ ) |
|
| 410 |
+ |
|
| 411 |
+ let meterSummary = meterSummaries.first { $0.macAddress == meter.btSerial.macAddress.description }
|
|
| 412 |
+ session.meterName = meterSummary?.displayName |
|
| 413 |
+ session.meterModel = meterSummary?.modelSummary |
|
| 414 |
+ |
|
| 415 |
+ session.onChange = { [weak self] in
|
|
| 416 |
+ self?.scheduleObjectWillChange() |
|
| 417 |
+ } |
|
| 418 |
+ session.onSample = { [weak self, weak session] sample in
|
|
| 419 |
+ guard let self, let session else { return }
|
|
| 420 |
+ self.consumptionMonitorStore.appendSample(sample, to: session.sessionID) |
|
| 421 |
+ } |
|
| 422 |
+ |
|
| 423 |
+ let initialRecord = ConsumptionMonitorSessionSummary( |
|
| 424 |
+ id: sessionID, |
|
| 425 |
+ chargedDeviceID: deviceID, |
|
| 426 |
+ meterMACAddress: meter.btSerial.macAddress.description, |
|
| 427 |
+ meterName: session.meterName, |
|
| 428 |
+ meterModel: session.meterModel, |
|
| 429 |
+ startedAt: now, |
|
| 430 |
+ endedAt: nil, |
|
| 431 |
+ samples: [] |
|
| 432 |
+ ) |
|
| 433 |
+ consumptionMonitorStore.save(initialRecord) |
|
| 434 |
+ |
|
| 435 |
+ activeConsumptionSessions[normalizedMAC] = session |
|
| 436 |
+ session.start() |
|
| 437 |
+ |
|
| 438 |
+ if meter.operationalState == .peripheralNotConnected {
|
|
| 439 |
+ meter.connect() |
|
| 440 |
+ } |
|
| 441 |
+ |
|
| 442 |
+ reloadChargedDevices() |
|
| 443 |
+ return true |
|
| 444 |
+ } |
|
| 445 |
+ |
|
| 446 |
+ @discardableResult |
|
| 447 |
+ func stopConsumptionMonitor(for meterMACAddress: String, save: Bool) -> Bool {
|
|
| 448 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 449 |
+ guard let session = activeConsumptionSessions[normalizedMAC] else { return false }
|
|
| 450 |
+ |
|
| 451 |
+ session.stop() |
|
| 452 |
+ activeConsumptionSessions[normalizedMAC] = nil |
|
| 453 |
+ |
|
| 454 |
+ if save {
|
|
| 455 |
+ consumptionMonitorStore.completeSession(id: session.sessionID, endedAt: Date()) |
|
| 456 |
+ } else {
|
|
| 457 |
+ consumptionMonitorStore.removeSession(id: session.sessionID, deviceID: session.chargedDeviceID) |
|
| 458 |
+ } |
|
| 459 |
+ |
|
| 460 |
+ reloadChargedDevices() |
|
| 461 |
+ return true |
|
| 462 |
+ } |
|
| 463 |
+ |
|
| 464 |
+ @discardableResult |
|
| 465 |
+ func deleteConsumptionSession(id: UUID, deviceID: UUID) -> Bool {
|
|
| 466 |
+ let didDelete = consumptionMonitorStore.removeSession(id: id, deviceID: deviceID) |
|
| 467 |
+ if didDelete { reloadChargedDevices() }
|
|
| 468 |
+ return didDelete |
|
| 469 |
+ } |
|
| 470 |
+ |
|
| 376 | 471 |
@discardableResult |
| 377 | 472 |
func createDevice( |
| 378 | 473 |
name: String, |
@@ -597,6 +692,40 @@ final class AppData : ObservableObject {
|
||
| 597 | 692 |
return didSave |
| 598 | 693 |
} |
| 599 | 694 |
|
| 695 |
+ @discardableResult |
|
| 696 |
+ func startPowerbankChargeSession( |
|
| 697 |
+ for meter: Meter, |
|
| 698 |
+ powerbankID: UUID, |
|
| 699 |
+ sourcePowerbankID: UUID? = nil, |
|
| 700 |
+ initialBatteryPercent: Double?, |
|
| 701 |
+ startsFromFlatBattery: Bool |
|
| 702 |
+ ) -> Bool {
|
|
| 703 |
+ meter.resetMeterCountersForNewSession() |
|
| 704 |
+ |
|
| 705 |
+ guard let snapshot = meter.chargingMonitorSnapshot else {
|
|
| 706 |
+ return false |
|
| 707 |
+ } |
|
| 708 |
+ |
|
| 709 |
+ let didSave = chargeInsightsStore?.startPowerbankSession( |
|
| 710 |
+ for: snapshot, |
|
| 711 |
+ powerbankID: powerbankID, |
|
| 712 |
+ sourcePowerbankID: sourcePowerbankID, |
|
| 713 |
+ autoStopEnabled: false, |
|
| 714 |
+ initialBatteryPercent: initialBatteryPercent, |
|
| 715 |
+ startsFromFlatBattery: startsFromFlatBattery |
|
| 716 |
+ ) ?? false |
|
| 717 |
+ if didSave {
|
|
| 718 |
+ meter.resetChargeRecordGraph() |
|
| 719 |
+ if let activeSession = chargeInsightsStore?.activeChargeSessionSummary( |
|
| 720 |
+ forMeterMACAddress: meter.btSerial.macAddress.description |
|
| 721 |
+ ) {
|
|
| 722 |
+ meter.restoreChargeMonitoringIfNeeded(from: activeSession) |
|
| 723 |
+ } |
|
| 724 |
+ reloadChargedDevices() |
|
| 725 |
+ } |
|
| 726 |
+ return didSave |
|
| 727 |
+ } |
|
| 728 |
+ |
|
| 600 | 729 |
@discardableResult |
| 601 | 730 |
func pauseChargeSession(sessionID: UUID, from meter: Meter? = nil) -> Bool {
|
| 602 | 731 |
let observedAt = meter?.chargingMonitorSnapshot?.observedAt ?? Date() |
@@ -646,6 +775,16 @@ final class AppData : ObservableObject {
|
||
| 646 | 775 |
} |
| 647 | 776 |
|
| 648 | 777 |
stageChargeObservation(snapshot) |
| 778 |
+ |
|
| 779 |
+ let normalizedMAC = Self.normalizedMACAddress(meter.btSerial.macAddress.description) |
|
| 780 |
+ if let consumptionSession = activeConsumptionSessions[normalizedMAC] {
|
|
| 781 |
+ consumptionSession.observe( |
|
| 782 |
+ powerWatts: snapshot.powerWatts, |
|
| 783 |
+ currentAmps: snapshot.currentAmps, |
|
| 784 |
+ voltageVolts: snapshot.voltageVolts, |
|
| 785 |
+ observedAt: observedAt |
|
| 786 |
+ ) |
|
| 787 |
+ } |
|
| 649 | 788 |
} |
| 650 | 789 |
|
| 651 | 790 |
@discardableResult |
@@ -1107,6 +1246,7 @@ final class AppData : ObservableObject {
|
||
| 1107 | 1246 |
} |
| 1108 | 1247 |
|
| 1109 | 1248 |
let standbyMeasurementsByChargerID = chargerStandbyPowerStore.measurementsByChargerID() |
| 1249 |
+ let consumptionSessionsByDeviceID = consumptionMonitorStore.sessionsByDeviceID() |
|
| 1110 | 1250 |
let readStore = chargeInsightsReadStore ?? chargeInsightsStore |
| 1111 | 1251 |
chargedDevicesReloadInFlight = true |
| 1112 | 1252 |
chargedDevicesReloadPending = false |
@@ -1116,9 +1256,9 @@ final class AppData : ObservableObject {
|
||
| 1116 | 1256 |
|
| 1117 | 1257 |
readStore?.resetContext() |
| 1118 | 1258 |
let summaries = (readStore?.fetchChargedDeviceSummaries() ?? []).map { chargedDevice in
|
| 1119 |
- chargedDevice.withStandbyPowerMeasurements( |
|
| 1120 |
- standbyMeasurementsByChargerID[chargedDevice.id] ?? [] |
|
| 1121 |
- ) |
|
| 1259 |
+ chargedDevice |
|
| 1260 |
+ .withStandbyPowerMeasurements(standbyMeasurementsByChargerID[chargedDevice.id] ?? []) |
|
| 1261 |
+ .withConsumptionSessions(consumptionSessionsByDeviceID[chargedDevice.id] ?? []) |
|
| 1122 | 1262 |
} |
| 1123 | 1263 |
let powerbankSummaries = readStore?.fetchPowerbankSummaries() ?? [] |
| 1124 | 1264 |
|
@@ -1829,6 +1829,55 @@ enum ChargerStandbyPowerMeasurementAnalyzer {
|
||
| 1829 | 1829 |
} |
| 1830 | 1830 |
} |
| 1831 | 1831 |
|
| 1832 |
+// MARK: - Consumption Monitor |
|
| 1833 |
+ |
|
| 1834 |
+struct ConsumptionMonitorSample: Identifiable, Codable, Hashable {
|
|
| 1835 |
+ var id: Int { bucketIndex }
|
|
| 1836 |
+ let bucketIndex: Int |
|
| 1837 |
+ let timestamp: Date |
|
| 1838 |
+ let averagePowerWatts: Double |
|
| 1839 |
+ let averageCurrentAmps: Double |
|
| 1840 |
+ let averageVoltageVolts: Double |
|
| 1841 |
+ let sampleCount: Int |
|
| 1842 |
+ let cumulativeEnergyWh: Double |
|
| 1843 |
+} |
|
| 1844 |
+ |
|
| 1845 |
+struct ConsumptionMonitorSessionSummary: Identifiable, Codable, Hashable {
|
|
| 1846 |
+ let id: UUID |
|
| 1847 |
+ let chargedDeviceID: UUID |
|
| 1848 |
+ let meterMACAddress: String |
|
| 1849 |
+ let meterName: String? |
|
| 1850 |
+ let meterModel: String? |
|
| 1851 |
+ let startedAt: Date |
|
| 1852 |
+ var endedAt: Date? |
|
| 1853 |
+ var samples: [ConsumptionMonitorSample] |
|
| 1854 |
+ |
|
| 1855 |
+ var isOpen: Bool { endedAt == nil }
|
|
| 1856 |
+ var duration: TimeInterval { (endedAt ?? Date()).timeIntervalSince(startedAt) }
|
|
| 1857 |
+ var totalEnergyWh: Double { samples.last?.cumulativeEnergyWh ?? 0 }
|
|
| 1858 |
+ var sampleCount: Int { samples.count }
|
|
| 1859 |
+ |
|
| 1860 |
+ var averagePowerWatts: Double {
|
|
| 1861 |
+ guard !samples.isEmpty else { return 0 }
|
|
| 1862 |
+ return samples.map(\.averagePowerWatts).reduce(0, +) / Double(samples.count) |
|
| 1863 |
+ } |
|
| 1864 |
+ var minimumPowerWatts: Double { samples.map(\.averagePowerWatts).min() ?? 0 }
|
|
| 1865 |
+ var maximumPowerWatts: Double { samples.map(\.averagePowerWatts).max() ?? 0 }
|
|
| 1866 |
+ var averageCurrentAmps: Double {
|
|
| 1867 |
+ guard !samples.isEmpty else { return 0 }
|
|
| 1868 |
+ return samples.map(\.averageCurrentAmps).reduce(0, +) / Double(samples.count) |
|
| 1869 |
+ } |
|
| 1870 |
+ var averageVoltageVolts: Double {
|
|
| 1871 |
+ guard !samples.isEmpty else { return 0 }
|
|
| 1872 |
+ return samples.map(\.averageVoltageVolts).reduce(0, +) / Double(samples.count) |
|
| 1873 |
+ } |
|
| 1874 |
+ |
|
| 1875 |
+ var projectedDailyEnergyWh: Double { averagePowerWatts * 24 }
|
|
| 1876 |
+ var projectedWeeklyEnergyWh: Double { averagePowerWatts * 24 * 7 }
|
|
| 1877 |
+ var projectedMonthlyEnergyWh: Double { averagePowerWatts * 24 * 30 }
|
|
| 1878 |
+ var projectedYearlyEnergyWh: Double { averagePowerWatts * 24 * 365 }
|
|
| 1879 |
+} |
|
| 1880 |
+ |
|
| 1832 | 1881 |
struct ChargedDeviceSummary: Identifiable, Hashable {
|
| 1833 | 1882 |
let id: UUID |
| 1834 | 1883 |
let qrIdentifier: String |
@@ -1866,6 +1915,7 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 1866 | 1915 |
let capacityHistory: [CapacityTrendPoint] |
| 1867 | 1916 |
let typicalCurve: [TypicalChargeCurvePoint] |
| 1868 | 1917 |
let standbyPowerMeasurements: [ChargerStandbyPowerMeasurementSummary] |
| 1918 |
+ let consumptionSessions: [ConsumptionMonitorSessionSummary] |
|
| 1869 | 1919 |
|
| 1870 | 1920 |
var isCharger: Bool {
|
| 1871 | 1921 |
deviceClass == .charger |
@@ -2308,7 +2358,50 @@ struct ChargedDeviceSummary: Identifiable, Hashable {
|
||
| 2308 | 2358 |
sessions: sessions, |
| 2309 | 2359 |
capacityHistory: capacityHistory, |
| 2310 | 2360 |
typicalCurve: typicalCurve, |
| 2311 |
- standbyPowerMeasurements: measurements |
|
| 2361 |
+ standbyPowerMeasurements: measurements, |
|
| 2362 |
+ consumptionSessions: consumptionSessions |
|
| 2363 |
+ ) |
|
| 2364 |
+ } |
|
| 2365 |
+ |
|
| 2366 |
+ func withConsumptionSessions(_ sessions: [ConsumptionMonitorSessionSummary]) -> ChargedDeviceSummary {
|
|
| 2367 |
+ ChargedDeviceSummary( |
|
| 2368 |
+ id: id, |
|
| 2369 |
+ qrIdentifier: qrIdentifier, |
|
| 2370 |
+ name: name, |
|
| 2371 |
+ deviceClass: deviceClass, |
|
| 2372 |
+ deviceTemplateID: deviceTemplateID, |
|
| 2373 |
+ templateDefinition: templateDefinition, |
|
| 2374 |
+ profileID: profileID, |
|
| 2375 |
+ hasInternalSubject: hasInternalSubject, |
|
| 2376 |
+ supportsChargingWhileOff: supportsChargingWhileOff, |
|
| 2377 |
+ chargingStateAvailability: chargingStateAvailability, |
|
| 2378 |
+ supportsWiredCharging: supportsWiredCharging, |
|
| 2379 |
+ supportsWirelessCharging: supportsWirelessCharging, |
|
| 2380 |
+ chargerType: chargerType, |
|
| 2381 |
+ wirelessChargingProfile: wirelessChargingProfile, |
|
| 2382 |
+ configuredCompletionCurrents: configuredCompletionCurrents, |
|
| 2383 |
+ learnedCompletionCurrents: learnedCompletionCurrents, |
|
| 2384 |
+ wirelessChargerEfficiencyFactor: wirelessChargerEfficiencyFactor, |
|
| 2385 |
+ wiredChargeCompletionCurrentAmps: wiredChargeCompletionCurrentAmps, |
|
| 2386 |
+ wirelessChargeCompletionCurrentAmps: wirelessChargeCompletionCurrentAmps, |
|
| 2387 |
+ chargerObservedVoltageSelections: chargerObservedVoltageSelections, |
|
| 2388 |
+ chargerIdleCurrentAmps: chargerIdleCurrentAmps, |
|
| 2389 |
+ chargerEfficiencyFactor: chargerEfficiencyFactor, |
|
| 2390 |
+ chargerMaximumPowerWatts: chargerMaximumPowerWatts, |
|
| 2391 |
+ notes: notes, |
|
| 2392 |
+ minimumCurrentAmps: minimumCurrentAmps, |
|
| 2393 |
+ estimatedBatteryCapacityWh: estimatedBatteryCapacityWh, |
|
| 2394 |
+ wiredMinimumCurrentAmps: wiredMinimumCurrentAmps, |
|
| 2395 |
+ wirelessMinimumCurrentAmps: wirelessMinimumCurrentAmps, |
|
| 2396 |
+ wiredEstimatedBatteryCapacityWh: wiredEstimatedBatteryCapacityWh, |
|
| 2397 |
+ wirelessEstimatedBatteryCapacityWh: wirelessEstimatedBatteryCapacityWh, |
|
| 2398 |
+ createdAt: createdAt, |
|
| 2399 |
+ updatedAt: updatedAt, |
|
| 2400 |
+ sessions: self.sessions, |
|
| 2401 |
+ capacityHistory: capacityHistory, |
|
| 2402 |
+ typicalCurve: typicalCurve, |
|
| 2403 |
+ standbyPowerMeasurements: standbyPowerMeasurements, |
|
| 2404 |
+ consumptionSessions: sessions |
|
| 2312 | 2405 |
) |
| 2313 | 2406 |
} |
| 2314 | 2407 |
} |
@@ -820,6 +820,68 @@ final class ChargeInsightsStore {
|
||
| 820 | 820 |
return didSave |
| 821 | 821 |
} |
| 822 | 822 |
|
| 823 |
+ @discardableResult |
|
| 824 |
+ func startPowerbankSession( |
|
| 825 |
+ for snapshot: ChargingMonitorSnapshot, |
|
| 826 |
+ powerbankID: UUID, |
|
| 827 |
+ sourcePowerbankID: UUID? = nil, |
|
| 828 |
+ autoStopEnabled: Bool, |
|
| 829 |
+ initialBatteryPercent: Double?, |
|
| 830 |
+ startsFromFlatBattery: Bool |
|
| 831 |
+ ) -> Bool {
|
|
| 832 |
+ if let initialBatteryPercent, |
|
| 833 |
+ (initialBatteryPercent.isFinite == false || initialBatteryPercent < 0 || initialBatteryPercent > 100) {
|
|
| 834 |
+ return false |
|
| 835 |
+ } |
|
| 836 |
+ |
|
| 837 |
+ var didSave = false |
|
| 838 |
+ context.performAndWait {
|
|
| 839 |
+ guard let powerbank = fetchPowerbankObject(id: powerbankID.uuidString) else {
|
|
| 840 |
+ return |
|
| 841 |
+ } |
|
| 842 |
+ |
|
| 843 |
+ guard fetchOpenSessionObject(forMeterMACAddress: snapshot.meterMACAddress) == nil else {
|
|
| 844 |
+ return |
|
| 845 |
+ } |
|
| 846 |
+ |
|
| 847 |
+ let powerbankSource = sourcePowerbankID == powerbankID |
|
| 848 |
+ ? nil |
|
| 849 |
+ : sourcePowerbankID.flatMap { fetchPowerbankObject(id: $0.uuidString) }
|
|
| 850 |
+ let stopThreshold = optionalDoubleValue(powerbank, key: "configuredCompletionCurrentAmps") |
|
| 851 |
+ ?? optionalDoubleValue(powerbank, key: "learnedCompletionCurrentAmps") |
|
| 852 |
+ ?? (snapshot.fallbackStopThresholdAmps > 0 ? snapshot.fallbackStopThresholdAmps : nil) |
|
| 853 |
+ |
|
| 854 |
+ guard let session = createPowerbankSubjectSessionObject( |
|
| 855 |
+ for: powerbank, |
|
| 856 |
+ snapshot: snapshot, |
|
| 857 |
+ stopThreshold: stopThreshold, |
|
| 858 |
+ autoStopEnabled: autoStopEnabled |
|
| 859 |
+ ) else {
|
|
| 860 |
+ return |
|
| 861 |
+ } |
|
| 862 |
+ if let powerbankSource, let powerbankIDString = stringValue(powerbankSource, key: "id") {
|
|
| 863 |
+ session.setValue(powerbankIDString, forKey: "sourcePowerbankID") |
|
| 864 |
+ } |
|
| 865 |
+ |
|
| 866 |
+ if startsFromFlatBattery {
|
|
| 867 |
+ session.setValue(unresolvedFlatBatteryPercent, forKey: "startBatteryPercent") |
|
| 868 |
+ session.setValue(nil, forKey: "endBatteryPercent") |
|
| 869 |
+ } else if let initialBatteryPercent {
|
|
| 870 |
+ guard insertBatteryCheckpoint( |
|
| 871 |
+ percent: initialBatteryPercent, |
|
| 872 |
+ flag: .initial, |
|
| 873 |
+ timestamp: snapshot.observedAt, |
|
| 874 |
+ subject: .powerbank, |
|
| 875 |
+ to: session |
|
| 876 |
+ ) != nil else {
|
|
| 877 |
+ return |
|
| 878 |
+ } |
|
| 879 |
+ } |
|
| 880 |
+ didSave = saveContext() |
|
| 881 |
+ } |
|
| 882 |
+ return didSave |
|
| 883 |
+ } |
|
| 884 |
+ |
|
| 823 | 885 |
@discardableResult |
| 824 | 886 |
func pauseSession(id sessionID: UUID, observedAt: Date) -> Bool {
|
| 825 | 887 |
var didSave = false |
@@ -1605,7 +1667,8 @@ final class ChargeInsightsStore {
|
||
| 1605 | 1667 |
sessions: sessionSummaries, |
| 1606 | 1668 |
capacityHistory: buildCapacityHistory(from: sessionSummaries), |
| 1607 | 1669 |
typicalCurve: buildTypicalCurve(from: sessionSummaries), |
| 1608 |
- standbyPowerMeasurements: [] |
|
| 1670 |
+ standbyPowerMeasurements: [], |
|
| 1671 |
+ consumptionSessions: [] |
|
| 1609 | 1672 |
) |
| 1610 | 1673 |
} |
| 1611 | 1674 |
.sorted { lhs, rhs in
|
@@ -1840,6 +1903,79 @@ final class ChargeInsightsStore {
|
||
| 1840 | 1903 |
return session |
| 1841 | 1904 |
} |
| 1842 | 1905 |
|
| 1906 |
+ private func createPowerbankSubjectSessionObject( |
|
| 1907 |
+ for powerbank: NSManagedObject, |
|
| 1908 |
+ snapshot: ChargingMonitorSnapshot, |
|
| 1909 |
+ stopThreshold: Double?, |
|
| 1910 |
+ autoStopEnabled: Bool |
|
| 1911 |
+ ) -> NSManagedObject? {
|
|
| 1912 |
+ guard |
|
| 1913 |
+ let entity = NSEntityDescription.entity(forEntityName: EntityName.chargeSession, in: context), |
|
| 1914 |
+ let powerbankID = stringValue(powerbank, key: "id") |
|
| 1915 |
+ else {
|
|
| 1916 |
+ return nil |
|
| 1917 |
+ } |
|
| 1918 |
+ |
|
| 1919 |
+ let session = NSManagedObject(entity: entity, insertInto: context) |
|
| 1920 |
+ let now = snapshot.observedAt |
|
| 1921 |
+ session.setValue(UUID().uuidString, forKey: "id") |
|
| 1922 |
+ session.setValue(powerbankID, forKey: "chargedDeviceID") |
|
| 1923 |
+ session.setValue(powerbankID, forKey: "chargedPowerbankID") |
|
| 1924 |
+ session.setValue(nil, forKey: "chargerID") |
|
| 1925 |
+ session.setValue(snapshot.meterMACAddress, forKey: "meterMACAddress") |
|
| 1926 |
+ session.setValue(snapshot.meterName, forKey: "meterName") |
|
| 1927 |
+ session.setValue(snapshot.meterModel, forKey: "meterModel") |
|
| 1928 |
+ session.setValue(now, forKey: "startedAt") |
|
| 1929 |
+ session.setValue(now, forKey: "lastObservedAt") |
|
| 1930 |
+ session.setValue(ChargeSessionStatus.active.rawValue, forKey: "statusRawValue") |
|
| 1931 |
+ let usesMeterCounters = snapshot.meterEnergyCounterWh != nil || snapshot.meterChargeCounterAh != nil |
|
| 1932 |
+ session.setValue( |
|
| 1933 |
+ (usesMeterCounters ? ChargeSessionSourceMode.offline : .live).rawValue, |
|
| 1934 |
+ forKey: "sourceModeRawValue" |
|
| 1935 |
+ ) |
|
| 1936 |
+ session.setValue(ChargingTransportMode.wired.rawValue, forKey: "chargingTransportRawValue") |
|
| 1937 |
+ session.setValue(ChargingStateMode.on.rawValue, forKey: "chargingStateRawValue") |
|
| 1938 |
+ session.setValue(autoStopEnabled, forKey: "autoStopEnabled") |
|
| 1939 |
+ session.setValue(stopThreshold ?? 0, forKey: "stopThresholdAmps") |
|
| 1940 |
+ session.setValue(snapshot.voltageVolts, forKey: "selectedSourceVoltageVolts") |
|
| 1941 |
+ session.setValue(snapshot.currentAmps, forKey: "lastObservedCurrentAmps") |
|
| 1942 |
+ session.setValue(snapshot.powerWatts, forKey: "lastObservedPowerWatts") |
|
| 1943 |
+ session.setValue(snapshot.voltageVolts, forKey: "lastObservedVoltageVolts") |
|
| 1944 |
+ session.setValue( |
|
| 1945 |
+ hasObservedChargeFlow( |
|
| 1946 |
+ currentAmps: snapshot.currentAmps, |
|
| 1947 |
+ chargingTransportMode: .wired, |
|
| 1948 |
+ charger: nil, |
|
| 1949 |
+ stopThreshold: stopThreshold |
|
| 1950 |
+ ), |
|
| 1951 |
+ forKey: "hasObservedChargeFlow" |
|
| 1952 |
+ ) |
|
| 1953 |
+ session.setValue(snapshot.currentAmps, forKey: "minimumObservedCurrentAmps") |
|
| 1954 |
+ session.setValue(snapshot.currentAmps, forKey: "maximumObservedCurrentAmps") |
|
| 1955 |
+ session.setValue(snapshot.powerWatts, forKey: "maximumObservedPowerWatts") |
|
| 1956 |
+ session.setValue(snapshot.voltageVolts, forKey: "maximumObservedVoltageVolts") |
|
| 1957 |
+ session.setValue(false, forKey: "supportsChargingWhileOff") |
|
| 1958 |
+ if let selectedDataGroup = snapshot.selectedDataGroup {
|
|
| 1959 |
+ session.setValue(Int16(selectedDataGroup), forKey: "selectedDataGroup") |
|
| 1960 |
+ } |
|
| 1961 |
+ if let meterEnergyCounterWh = snapshot.meterEnergyCounterWh {
|
|
| 1962 |
+ session.setValue(meterEnergyCounterWh, forKey: "meterEnergyBaselineWh") |
|
| 1963 |
+ session.setValue(meterEnergyCounterWh, forKey: "meterLastEnergyWh") |
|
| 1964 |
+ } |
|
| 1965 |
+ if let meterChargeCounterAh = snapshot.meterChargeCounterAh {
|
|
| 1966 |
+ session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh") |
|
| 1967 |
+ session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh") |
|
| 1968 |
+ } |
|
| 1969 |
+ if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
|
|
| 1970 |
+ setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds") |
|
| 1971 |
+ setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds") |
|
| 1972 |
+ } |
|
| 1973 |
+ session.setValue(now, forKey: "createdAt") |
|
| 1974 |
+ session.setValue(now, forKey: "updatedAt") |
|
| 1975 |
+ |
|
| 1976 |
+ return session |
|
| 1977 |
+ } |
|
| 1978 |
+ |
|
| 1843 | 1979 |
private func update( |
| 1844 | 1980 |
session: NSManagedObject, |
| 1845 | 1981 |
with snapshot: ChargingMonitorSnapshot, |
@@ -2265,6 +2401,7 @@ final class ChargeInsightsStore {
|
||
| 2265 | 2401 |
percent: finalBatteryPercent, |
| 2266 | 2402 |
flag: .final, |
| 2267 | 2403 |
timestamp: observedAt, |
| 2404 |
+ subject: stringValue(session, key: "chargedPowerbankID") == nil ? .chargedDevice : .powerbank, |
|
| 2268 | 2405 |
to: session |
| 2269 | 2406 |
) |
| 2270 | 2407 |
} |
@@ -2688,10 +2825,11 @@ final class ChargeInsightsStore {
|
||
| 2688 | 2825 |
checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
| 2689 | 2826 |
checkpoint.setValue(nil, forKey: "powerbankID") |
| 2690 | 2827 |
case .powerbank: |
| 2691 |
- // Powerbank-side checkpoint: link to the powerbank source instead. ChargedDeviceID |
|
| 2692 |
- // stays nil so device capacity learning ignores it; the session backref is via sessionID. |
|
| 2693 |
- let powerbankID = stringValue(session, key: "sourcePowerbankID") |
|
| 2694 |
- checkpoint.setValue(nil, forKey: "chargedDeviceID") |
|
| 2828 |
+ // Link to the charged powerbank when it is the session subject, otherwise |
|
| 2829 |
+ // to the source powerbank being monitored alongside a device session. |
|
| 2830 |
+ let powerbankID = stringValue(session, key: "chargedPowerbankID") |
|
| 2831 |
+ ?? stringValue(session, key: "sourcePowerbankID") |
|
| 2832 |
+ checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID") |
|
| 2695 | 2833 |
checkpoint.setValue(powerbankID, forKey: "powerbankID") |
| 2696 | 2834 |
} |
| 2697 | 2835 |
checkpoint.setValue(Int16(max(0, barsValue)), forKey: "batteryBarsValue") |
@@ -2706,8 +2844,8 @@ final class ChargeInsightsStore {
|
||
| 2706 | 2844 |
checkpoint.setValue(flag.rawValue, forKey: "label") |
| 2707 | 2845 |
checkpoint.setValue(timestamp, forKey: "createdAt") |
| 2708 | 2846 |
|
| 2709 |
- // Session start/end battery percent fields track the device subject only. |
|
| 2710 |
- if subject == .chargedDevice {
|
|
| 2847 |
+ let tracksSessionSubject = subject == .chargedDevice || stringValue(session, key: "chargedPowerbankID") != nil |
|
| 2848 |
+ if tracksSessionSubject {
|
|
| 2711 | 2849 |
let existingStartBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent") |
| 2712 | 2850 |
if existingStartBatteryPercent == nil {
|
| 2713 | 2851 |
session.setValue(percent, forKey: "startBatteryPercent") |
@@ -3260,7 +3398,7 @@ final class ChargeInsightsStore {
|
||
| 3260 | 3398 |
guard |
| 3261 | 3399 |
let id = uuidValue(object, key: "id"), |
| 3262 | 3400 |
let sessionID = uuidValue(object, key: "sessionID"), |
| 3263 |
- let chargedDeviceID = uuidValue(object, key: "chargedDeviceID"), |
|
| 3401 |
+ let chargedDeviceID = uuidValue(object, key: "chargedDeviceID") ?? uuidValue(object, key: "powerbankID"), |
|
| 3264 | 3402 |
let timestamp = dateValue(object, key: "timestamp") |
| 3265 | 3403 |
else {
|
| 3266 | 3404 |
return nil |
@@ -0,0 +1,356 @@ |
||
| 1 |
+// |
|
| 2 |
+// ConsumptionMonitorStore.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import Foundation |
|
| 7 |
+import Combine |
|
| 8 |
+ |
|
| 9 |
+// MARK: - Store |
|
| 10 |
+ |
|
| 11 |
+final class ConsumptionMonitorStore {
|
|
| 12 |
+ private struct Snapshot: Codable {
|
|
| 13 |
+ var sessions: [ConsumptionMonitorSessionSummary] |
|
| 14 |
+ } |
|
| 15 |
+ |
|
| 16 |
+ private enum Keys {
|
|
| 17 |
+ static let cloudSessions = "ConsumptionMonitorStore.sessions" |
|
| 18 |
+ } |
|
| 19 |
+ |
|
| 20 |
+ private let fileManager: FileManager |
|
| 21 |
+ private let fileURL: URL |
|
| 22 |
+ private let encoder: JSONEncoder |
|
| 23 |
+ private let decoder: JSONDecoder |
|
| 24 |
+ private let ubiquitousStore = NSUbiquitousKeyValueStore.default |
|
| 25 |
+ private let workQueue = DispatchQueue(label: "ConsumptionMonitorStore.Queue") |
|
| 26 |
+ private var ubiquitousObserver: NSObjectProtocol? |
|
| 27 |
+ private var ubiquityIdentityObserver: NSObjectProtocol? |
|
| 28 |
+ |
|
| 29 |
+ private var cachedSessions: [ConsumptionMonitorSessionSummary]? |
|
| 30 |
+ |
|
| 31 |
+ init(fileManager: FileManager = .default) {
|
|
| 32 |
+ self.fileManager = fileManager |
|
| 33 |
+ |
|
| 34 |
+ let applicationSupportURL = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first |
|
| 35 |
+ ?? fileManager.urls(for: .documentDirectory, in: .userDomainMask).first |
|
| 36 |
+ ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) |
|
| 37 |
+ |
|
| 38 |
+ let directoryURL = applicationSupportURL.appendingPathComponent("ChargeInsights", isDirectory: true)
|
|
| 39 |
+ fileURL = directoryURL.appendingPathComponent("consumption-monitor.json", isDirectory: false)
|
|
| 40 |
+ |
|
| 41 |
+ encoder = JSONEncoder() |
|
| 42 |
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
|
| 43 |
+ encoder.dateEncodingStrategy = .iso8601 |
|
| 44 |
+ |
|
| 45 |
+ decoder = JSONDecoder() |
|
| 46 |
+ decoder.dateDecodingStrategy = .iso8601 |
|
| 47 |
+ |
|
| 48 |
+ ubiquitousObserver = NotificationCenter.default.addObserver( |
|
| 49 |
+ forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, |
|
| 50 |
+ object: ubiquitousStore, |
|
| 51 |
+ queue: nil |
|
| 52 |
+ ) { [weak self] notification in
|
|
| 53 |
+ self?.handleUbiquitousStoreChange(notification) |
|
| 54 |
+ } |
|
| 55 |
+ |
|
| 56 |
+ ubiquityIdentityObserver = NotificationCenter.default.addObserver( |
|
| 57 |
+ forName: NSNotification.Name.NSUbiquityIdentityDidChange, |
|
| 58 |
+ object: nil, |
|
| 59 |
+ queue: nil |
|
| 60 |
+ ) { [weak self] _ in
|
|
| 61 |
+ self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed") |
|
| 62 |
+ } |
|
| 63 |
+ |
|
| 64 |
+ ubiquitousStore.synchronize() |
|
| 65 |
+ syncLocalValuesToCloudIfPossible(reason: "startup") |
|
| 66 |
+ } |
|
| 67 |
+ |
|
| 68 |
+ func sessionsByDeviceID() -> [UUID: [ConsumptionMonitorSessionSummary]] {
|
|
| 69 |
+ Dictionary(grouping: loadSessions()) { $0.chargedDeviceID }
|
|
| 70 |
+ .mapValues { sessions in
|
|
| 71 |
+ sessions.sorted { lhs, rhs in
|
|
| 72 |
+ (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) |
|
| 73 |
+ } |
|
| 74 |
+ } |
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ @discardableResult |
|
| 78 |
+ func save(_ session: ConsumptionMonitorSessionSummary) -> Bool {
|
|
| 79 |
+ var sessions = loadSessions() |
|
| 80 |
+ if let index = sessions.firstIndex(where: { $0.id == session.id }) {
|
|
| 81 |
+ sessions[index] = session |
|
| 82 |
+ } else {
|
|
| 83 |
+ sessions.append(session) |
|
| 84 |
+ } |
|
| 85 |
+ sessions.sort { lhs, rhs in
|
|
| 86 |
+ (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) |
|
| 87 |
+ } |
|
| 88 |
+ return persist(sessions) |
|
| 89 |
+ } |
|
| 90 |
+ |
|
| 91 |
+ @discardableResult |
|
| 92 |
+ func appendSample(_ sample: ConsumptionMonitorSample, to sessionID: UUID) -> Bool {
|
|
| 93 |
+ var sessions = loadSessions() |
|
| 94 |
+ guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else {
|
|
| 95 |
+ return false |
|
| 96 |
+ } |
|
| 97 |
+ sessions[index].samples.append(sample) |
|
| 98 |
+ return persist(sessions) |
|
| 99 |
+ } |
|
| 100 |
+ |
|
| 101 |
+ @discardableResult |
|
| 102 |
+ func completeSession(id sessionID: UUID, endedAt: Date) -> Bool {
|
|
| 103 |
+ var sessions = loadSessions() |
|
| 104 |
+ guard let index = sessions.firstIndex(where: { $0.id == sessionID }) else {
|
|
| 105 |
+ return false |
|
| 106 |
+ } |
|
| 107 |
+ sessions[index].endedAt = endedAt |
|
| 108 |
+ sessions.sort { lhs, rhs in
|
|
| 109 |
+ (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) |
|
| 110 |
+ } |
|
| 111 |
+ return persist(sessions) |
|
| 112 |
+ } |
|
| 113 |
+ |
|
| 114 |
+ @discardableResult |
|
| 115 |
+ func removeSession(id: UUID, deviceID: UUID) -> Bool {
|
|
| 116 |
+ let previous = loadSessions() |
|
| 117 |
+ let filtered = previous.filter { !($0.id == id && $0.chargedDeviceID == deviceID) }
|
|
| 118 |
+ guard filtered.count != previous.count else { return true }
|
|
| 119 |
+ return persist(filtered) |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ @discardableResult |
|
| 123 |
+ func removeSessions(for deviceID: UUID) -> Bool {
|
|
| 124 |
+ let previous = loadSessions() |
|
| 125 |
+ let filtered = previous.filter { $0.chargedDeviceID != deviceID }
|
|
| 126 |
+ guard filtered.count != previous.count else { return true }
|
|
| 127 |
+ return persist(filtered) |
|
| 128 |
+ } |
|
| 129 |
+ |
|
| 130 |
+ func openSession(for meterMACAddress: String) -> ConsumptionMonitorSessionSummary? {
|
|
| 131 |
+ loadSessions().first { $0.isOpen && $0.meterMACAddress == meterMACAddress }
|
|
| 132 |
+ } |
|
| 133 |
+ |
|
| 134 |
+ // MARK: - Private |
|
| 135 |
+ |
|
| 136 |
+ private func loadSessions() -> [ConsumptionMonitorSessionSummary] {
|
|
| 137 |
+ if let cachedSessions { return cachedSessions }
|
|
| 138 |
+ let local = loadLocalSessions() |
|
| 139 |
+ let cloud = loadCloudSessions() |
|
| 140 |
+ let merged = merge(localSessions: local, cloudSessions: cloud) |
|
| 141 |
+ cachedSessions = merged |
|
| 142 |
+ return merged |
|
| 143 |
+ } |
|
| 144 |
+ |
|
| 145 |
+ private func loadLocalSessions() -> [ConsumptionMonitorSessionSummary] {
|
|
| 146 |
+ guard fileManager.fileExists(atPath: fileURL.path) else { return [] }
|
|
| 147 |
+ do {
|
|
| 148 |
+ let data = try Data(contentsOf: fileURL) |
|
| 149 |
+ return try decoder.decode(Snapshot.self, from: data).sessions |
|
| 150 |
+ } catch {
|
|
| 151 |
+ track("ConsumptionMonitorStore: failed to load local sessions: \(error.localizedDescription)")
|
|
| 152 |
+ return [] |
|
| 153 |
+ } |
|
| 154 |
+ } |
|
| 155 |
+ |
|
| 156 |
+ private func loadCloudSessions() -> [ConsumptionMonitorSessionSummary] {
|
|
| 157 |
+ guard isICloudDriveAvailable, |
|
| 158 |
+ let data = ubiquitousStore.data(forKey: Keys.cloudSessions) else { return [] }
|
|
| 159 |
+ do {
|
|
| 160 |
+ return try decoder.decode(Snapshot.self, from: data).sessions |
|
| 161 |
+ } catch {
|
|
| 162 |
+ track("ConsumptionMonitorStore: failed to decode cloud sessions: \(error.localizedDescription)")
|
|
| 163 |
+ return [] |
|
| 164 |
+ } |
|
| 165 |
+ } |
|
| 166 |
+ |
|
| 167 |
+ @discardableResult |
|
| 168 |
+ private func persist(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
|
|
| 169 |
+ let didLocal = persistLocally(sessions) |
|
| 170 |
+ let didCloud = persistToCloudIfPossible(sessions) |
|
| 171 |
+ if didLocal || didCloud {
|
|
| 172 |
+ cachedSessions = sessions |
|
| 173 |
+ } |
|
| 174 |
+ return didLocal || didCloud |
|
| 175 |
+ } |
|
| 176 |
+ |
|
| 177 |
+ @discardableResult |
|
| 178 |
+ private func persistLocally(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
|
|
| 179 |
+ do {
|
|
| 180 |
+ try fileManager.createDirectory( |
|
| 181 |
+ at: fileURL.deletingLastPathComponent(), |
|
| 182 |
+ withIntermediateDirectories: true, |
|
| 183 |
+ attributes: nil |
|
| 184 |
+ ) |
|
| 185 |
+ let data = try encoder.encode(Snapshot(sessions: sessions)) |
|
| 186 |
+ try data.write(to: fileURL, options: .atomic) |
|
| 187 |
+ return true |
|
| 188 |
+ } catch {
|
|
| 189 |
+ track("ConsumptionMonitorStore: failed to save locally: \(error.localizedDescription)")
|
|
| 190 |
+ return false |
|
| 191 |
+ } |
|
| 192 |
+ } |
|
| 193 |
+ |
|
| 194 |
+ @discardableResult |
|
| 195 |
+ private func persistToCloudIfPossible(_ sessions: [ConsumptionMonitorSessionSummary]) -> Bool {
|
|
| 196 |
+ guard isICloudDriveAvailable else { return false }
|
|
| 197 |
+ do {
|
|
| 198 |
+ let data = try encoder.encode(Snapshot(sessions: sessions)) |
|
| 199 |
+ ubiquitousStore.set(data, forKey: Keys.cloudSessions) |
|
| 200 |
+ return ubiquitousStore.synchronize() |
|
| 201 |
+ } catch {
|
|
| 202 |
+ track("ConsumptionMonitorStore: failed to sync to cloud: \(error.localizedDescription)")
|
|
| 203 |
+ return false |
|
| 204 |
+ } |
|
| 205 |
+ } |
|
| 206 |
+ |
|
| 207 |
+ private func merge( |
|
| 208 |
+ localSessions: [ConsumptionMonitorSessionSummary], |
|
| 209 |
+ cloudSessions: [ConsumptionMonitorSessionSummary] |
|
| 210 |
+ ) -> [ConsumptionMonitorSessionSummary] {
|
|
| 211 |
+ var byID: [UUID: ConsumptionMonitorSessionSummary] = [:] |
|
| 212 |
+ for session in localSessions { byID[session.id] = session }
|
|
| 213 |
+ for session in cloudSessions {
|
|
| 214 |
+ if let existing = byID[session.id] {
|
|
| 215 |
+ // Keep the one with more samples or a definitive end time |
|
| 216 |
+ if session.samples.count > existing.samples.count || (session.endedAt != nil && existing.endedAt == nil) {
|
|
| 217 |
+ byID[session.id] = session |
|
| 218 |
+ } |
|
| 219 |
+ } else {
|
|
| 220 |
+ byID[session.id] = session |
|
| 221 |
+ } |
|
| 222 |
+ } |
|
| 223 |
+ return byID.values.sorted { lhs, rhs in
|
|
| 224 |
+ (lhs.endedAt ?? .distantFuture) > (rhs.endedAt ?? .distantFuture) |
|
| 225 |
+ } |
|
| 226 |
+ } |
|
| 227 |
+ |
|
| 228 |
+ private func syncLocalValuesToCloudIfPossible(reason: String) {
|
|
| 229 |
+ let sessions = loadLocalSessions() |
|
| 230 |
+ guard !sessions.isEmpty else { return }
|
|
| 231 |
+ persistToCloudIfPossible(sessions) |
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ private func handleUbiquitousStoreChange(_ notification: Notification) {
|
|
| 235 |
+ cachedSessions = nil |
|
| 236 |
+ NotificationCenter.default.post(name: .consumptionMonitorStoreDidChange, object: nil) |
|
| 237 |
+ } |
|
| 238 |
+ |
|
| 239 |
+ private var isICloudDriveAvailable: Bool {
|
|
| 240 |
+ FileManager.default.ubiquityIdentityToken != nil |
|
| 241 |
+ } |
|
| 242 |
+} |
|
| 243 |
+ |
|
| 244 |
+extension Notification.Name {
|
|
| 245 |
+ static let consumptionMonitorStoreDidChange = Notification.Name("ConsumptionMonitorStore.DidChange")
|
|
| 246 |
+} |
|
| 247 |
+ |
|
| 248 |
+// MARK: - Live Session |
|
| 249 |
+ |
|
| 250 |
+final class ConsumptionMonitorLiveSession: ObservableObject {
|
|
| 251 |
+ static let bucketDurationSeconds: TimeInterval = 60 |
|
| 252 |
+ |
|
| 253 |
+ let sessionID: UUID |
|
| 254 |
+ let chargedDeviceID: UUID |
|
| 255 |
+ let meterMACAddress: String |
|
| 256 |
+ let startedAt: Date |
|
| 257 |
+ |
|
| 258 |
+ @Published private(set) var currentPowerWatts: Double = 0 |
|
| 259 |
+ @Published private(set) var currentCurrentAmps: Double = 0 |
|
| 260 |
+ @Published private(set) var currentVoltageVolts: Double = 0 |
|
| 261 |
+ @Published private(set) var committedSampleCount: Int = 0 |
|
| 262 |
+ @Published private(set) var committedSamples: [ConsumptionMonitorSample] = [] |
|
| 263 |
+ @Published private(set) var cumulativeEnergyWh: Double = 0 |
|
| 264 |
+ @Published private(set) var isRunning: Bool = false |
|
| 265 |
+ |
|
| 266 |
+ var meterName: String? |
|
| 267 |
+ var meterModel: String? |
|
| 268 |
+ var onSample: ((ConsumptionMonitorSample) -> Void)? |
|
| 269 |
+ var onChange: (() -> Void)? |
|
| 270 |
+ |
|
| 271 |
+ private var flushTimer: Timer? |
|
| 272 |
+ private var powerReadings: [(power: Double, current: Double, voltage: Double)] = [] |
|
| 273 |
+ private var lastObservationTime: Date? |
|
| 274 |
+ private var nextBucketIndex: Int = 0 |
|
| 275 |
+ |
|
| 276 |
+ init(sessionID: UUID, chargedDeviceID: UUID, meterMACAddress: String, startedAt: Date) {
|
|
| 277 |
+ self.sessionID = sessionID |
|
| 278 |
+ self.chargedDeviceID = chargedDeviceID |
|
| 279 |
+ self.meterMACAddress = meterMACAddress |
|
| 280 |
+ self.startedAt = startedAt |
|
| 281 |
+ } |
|
| 282 |
+ |
|
| 283 |
+ func start() {
|
|
| 284 |
+ guard !isRunning else { return }
|
|
| 285 |
+ isRunning = true |
|
| 286 |
+ scheduleNextFlush() |
|
| 287 |
+ } |
|
| 288 |
+ |
|
| 289 |
+ func stop() {
|
|
| 290 |
+ isRunning = false |
|
| 291 |
+ flushTimer?.invalidate() |
|
| 292 |
+ flushTimer = nil |
|
| 293 |
+ flushBucket() |
|
| 294 |
+ } |
|
| 295 |
+ |
|
| 296 |
+ func observe(powerWatts: Double, currentAmps: Double, voltageVolts: Double, observedAt: Date) {
|
|
| 297 |
+ currentPowerWatts = powerWatts |
|
| 298 |
+ currentCurrentAmps = currentAmps |
|
| 299 |
+ currentVoltageVolts = voltageVolts |
|
| 300 |
+ |
|
| 301 |
+ if let last = lastObservationTime {
|
|
| 302 |
+ let dtHours = observedAt.timeIntervalSince(last) / 3600 |
|
| 303 |
+ cumulativeEnergyWh += powerWatts * dtHours |
|
| 304 |
+ } |
|
| 305 |
+ lastObservationTime = observedAt |
|
| 306 |
+ |
|
| 307 |
+ powerReadings.append((powerWatts, currentAmps, voltageVolts)) |
|
| 308 |
+ onChange?() |
|
| 309 |
+ } |
|
| 310 |
+ |
|
| 311 |
+ var elapsedDuration: TimeInterval {
|
|
| 312 |
+ Date().timeIntervalSince(startedAt) |
|
| 313 |
+ } |
|
| 314 |
+ |
|
| 315 |
+ // MARK: - Private |
|
| 316 |
+ |
|
| 317 |
+ private func scheduleNextFlush() {
|
|
| 318 |
+ flushTimer?.invalidate() |
|
| 319 |
+ flushTimer = Timer.scheduledTimer( |
|
| 320 |
+ withTimeInterval: Self.bucketDurationSeconds, |
|
| 321 |
+ repeats: false |
|
| 322 |
+ ) { [weak self] _ in
|
|
| 323 |
+ self?.flushBucket() |
|
| 324 |
+ if self?.isRunning == true {
|
|
| 325 |
+ self?.scheduleNextFlush() |
|
| 326 |
+ } |
|
| 327 |
+ } |
|
| 328 |
+ } |
|
| 329 |
+ |
|
| 330 |
+ private func flushBucket() {
|
|
| 331 |
+ guard !powerReadings.isEmpty else { return }
|
|
| 332 |
+ |
|
| 333 |
+ let n = Double(powerReadings.count) |
|
| 334 |
+ let avgPower = powerReadings.map(\.power).reduce(0, +) / n |
|
| 335 |
+ let avgCurrent = powerReadings.map(\.current).reduce(0, +) / n |
|
| 336 |
+ let avgVoltage = powerReadings.map(\.voltage).reduce(0, +) / n |
|
| 337 |
+ let bucketIndex = nextBucketIndex |
|
| 338 |
+ nextBucketIndex += 1 |
|
| 339 |
+ |
|
| 340 |
+ let sample = ConsumptionMonitorSample( |
|
| 341 |
+ bucketIndex: bucketIndex, |
|
| 342 |
+ timestamp: Date(), |
|
| 343 |
+ averagePowerWatts: avgPower, |
|
| 344 |
+ averageCurrentAmps: avgCurrent, |
|
| 345 |
+ averageVoltageVolts: avgVoltage, |
|
| 346 |
+ sampleCount: powerReadings.count, |
|
| 347 |
+ cumulativeEnergyWh: cumulativeEnergyWh |
|
| 348 |
+ ) |
|
| 349 |
+ |
|
| 350 |
+ powerReadings = [] |
|
| 351 |
+ committedSamples.append(sample) |
|
| 352 |
+ committedSampleCount = nextBucketIndex |
|
| 353 |
+ onSample?(sample) |
|
| 354 |
+ onChange?() |
|
| 355 |
+ } |
|
| 356 |
+} |
|
@@ -0,0 +1,432 @@ |
||
| 1 |
+// |
|
| 2 |
+// ConsumptionMonitorView.swift |
|
| 3 |
+// USB Meter |
|
| 4 |
+// |
|
| 5 |
+ |
|
| 6 |
+import SwiftUI |
|
| 7 |
+import Charts |
|
| 8 |
+ |
|
| 9 |
+// MARK: - Shared helpers (file-private) |
|
| 10 |
+ |
|
| 11 |
+private func formattedDuration(_ duration: TimeInterval) -> String {
|
|
| 12 |
+ let formatter = DateComponentsFormatter() |
|
| 13 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 14 |
+ formatter.unitsStyle = .abbreviated |
|
| 15 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 16 |
+ return formatter.string(from: max(duration, 0)) ?? "0m" |
|
| 17 |
+} |
|
| 18 |
+ |
|
| 19 |
+private func energyLabel(_ wattHours: Double) -> String {
|
|
| 20 |
+ wattHours >= 1000 |
|
| 21 |
+ ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 22 |
+ : "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 23 |
+} |
|
| 24 |
+ |
|
| 25 |
+@available(iOS 16, *) |
|
| 26 |
+private func consumptionChart(samples: [ConsumptionMonitorSample], tint: Color) -> some View {
|
|
| 27 |
+ let duration = samples.last.map { $0.timestamp.timeIntervalSince(samples[0].timestamp) } ?? 0
|
|
| 28 |
+ return Chart(samples) { sample in
|
|
| 29 |
+ LineMark( |
|
| 30 |
+ x: .value("Time", sample.timestamp),
|
|
| 31 |
+ y: .value("W", sample.averagePowerWatts)
|
|
| 32 |
+ ) |
|
| 33 |
+ .foregroundStyle(tint) |
|
| 34 |
+ .interpolationMethod(.catmullRom) |
|
| 35 |
+ } |
|
| 36 |
+ .frame(height: 140) |
|
| 37 |
+ .chartYScale(domain: .automatic(includesZero: false)) |
|
| 38 |
+ .chartXAxis {
|
|
| 39 |
+ if duration > 3600 {
|
|
| 40 |
+ AxisMarks(values: .stride(by: .hour)) { _ in
|
|
| 41 |
+ AxisGridLine() |
|
| 42 |
+ AxisValueLabel(format: .dateTime.hour()) |
|
| 43 |
+ } |
|
| 44 |
+ } else {
|
|
| 45 |
+ AxisMarks(values: .stride(by: .minute, count: 10)) { _ in
|
|
| 46 |
+ AxisGridLine() |
|
| 47 |
+ AxisValueLabel(format: .dateTime.hour(.defaultDigits(amPM: .omitted)).minute()) |
|
| 48 |
+ } |
|
| 49 |
+ } |
|
| 50 |
+ } |
|
| 51 |
+ .chartYAxis {
|
|
| 52 |
+ AxisMarks { value in
|
|
| 53 |
+ AxisGridLine() |
|
| 54 |
+ AxisValueLabel {
|
|
| 55 |
+ if let v = value.as(Double.self) {
|
|
| 56 |
+ Text("\(v.format(decimalDigits: 1)) W")
|
|
| 57 |
+ } |
|
| 58 |
+ } |
|
| 59 |
+ } |
|
| 60 |
+ } |
|
| 61 |
+} |
|
| 62 |
+ |
|
| 63 |
+// MARK: - Main View |
|
| 64 |
+ |
|
| 65 |
+struct ConsumptionMonitorView: View {
|
|
| 66 |
+ @EnvironmentObject private var appData: AppData |
|
| 67 |
+ |
|
| 68 |
+ @State private var selectedMeterMACAddress: String? |
|
| 69 |
+ @State private var selectedDeviceID: UUID? |
|
| 70 |
+ @State private var discardConfirmationVisibility = false |
|
| 71 |
+ |
|
| 72 |
+ let preferredMeterMACAddress: String? |
|
| 73 |
+ |
|
| 74 |
+ init(preferredMeterMACAddress: String? = nil) {
|
|
| 75 |
+ self.preferredMeterMACAddress = preferredMeterMACAddress |
|
| 76 |
+ _selectedMeterMACAddress = State(initialValue: preferredMeterMACAddress) |
|
| 77 |
+ } |
|
| 78 |
+ |
|
| 79 |
+ var body: some View {
|
|
| 80 |
+ ScrollView {
|
|
| 81 |
+ VStack(spacing: 18) {
|
|
| 82 |
+ if let session = activeSession {
|
|
| 83 |
+ activeSessionCard(session) |
|
| 84 |
+ liveMetricsCard(session) |
|
| 85 |
+ } else {
|
|
| 86 |
+ setupCard |
|
| 87 |
+ } |
|
| 88 |
+ savedSessionsList |
|
| 89 |
+ } |
|
| 90 |
+ .padding() |
|
| 91 |
+ } |
|
| 92 |
+ .background( |
|
| 93 |
+ LinearGradient( |
|
| 94 |
+ colors: [.purple.opacity(0.16), Color.clear], |
|
| 95 |
+ startPoint: .topLeading, |
|
| 96 |
+ endPoint: .bottomTrailing |
|
| 97 |
+ ) |
|
| 98 |
+ .ignoresSafeArea() |
|
| 99 |
+ ) |
|
| 100 |
+ .navigationTitle("Consumption Monitor")
|
|
| 101 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 102 |
+ .confirmationDialog( |
|
| 103 |
+ "Stop and discard this session?", |
|
| 104 |
+ isPresented: $discardConfirmationVisibility, |
|
| 105 |
+ titleVisibility: .visible |
|
| 106 |
+ ) {
|
|
| 107 |
+ Button("Discard", role: .destructive) {
|
|
| 108 |
+ if let session = activeSession {
|
|
| 109 |
+ _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: false) |
|
| 110 |
+ } |
|
| 111 |
+ } |
|
| 112 |
+ Button("Cancel", role: .cancel) {}
|
|
| 113 |
+ } message: {
|
|
| 114 |
+ Text("The current session data will be lost and nothing will be saved.")
|
|
| 115 |
+ } |
|
| 116 |
+ } |
|
| 117 |
+ |
|
| 118 |
+ // MARK: - Computed |
|
| 119 |
+ |
|
| 120 |
+ private var liveMeterSummaries: [AppData.MeterSummary] {
|
|
| 121 |
+ appData.meterSummaries.filter { $0.meter != nil }
|
|
| 122 |
+ } |
|
| 123 |
+ |
|
| 124 |
+ private var availableDevices: [ChargedDeviceSummary] {
|
|
| 125 |
+ appData.deviceSummaries |
|
| 126 |
+ } |
|
| 127 |
+ |
|
| 128 |
+ private var activeSession: ConsumptionMonitorLiveSession? {
|
|
| 129 |
+ let candidates = [selectedMeterMACAddress, preferredMeterMACAddress].compactMap { $0 }
|
|
| 130 |
+ for mac in candidates {
|
|
| 131 |
+ if let session = appData.consumptionMonitorSession(for: mac) { return session }
|
|
| 132 |
+ } |
|
| 133 |
+ for summary in liveMeterSummaries {
|
|
| 134 |
+ if let session = appData.consumptionMonitorSession(for: summary.macAddress) { return session }
|
|
| 135 |
+ } |
|
| 136 |
+ return nil |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ private var selectedDevice: ChargedDeviceSummary? {
|
|
| 140 |
+ guard let id = selectedDeviceID else { return nil }
|
|
| 141 |
+ return availableDevices.first { $0.id == id }
|
|
| 142 |
+ } |
|
| 143 |
+ |
|
| 144 |
+ private var selectedMeterSummary: AppData.MeterSummary? {
|
|
| 145 |
+ guard let mac = selectedMeterMACAddress else { return nil }
|
|
| 146 |
+ return liveMeterSummaries.first { $0.macAddress == mac }
|
|
| 147 |
+ } |
|
| 148 |
+ |
|
| 149 |
+ private var savedSessions: [ConsumptionMonitorSessionSummary] {
|
|
| 150 |
+ guard let id = selectedDeviceID else { return [] }
|
|
| 151 |
+ return appData.chargedDeviceSummary(id: id)?.consumptionSessions.filter { !$0.isOpen } ?? []
|
|
| 152 |
+ } |
|
| 153 |
+ |
|
| 154 |
+ private var canStart: Bool {
|
|
| 155 |
+ selectedDeviceID != nil && selectedMeterSummary != nil && activeSession == nil |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ // MARK: - Setup Card |
|
| 159 |
+ |
|
| 160 |
+ private var setupCard: some View {
|
|
| 161 |
+ MeterInfoCardView(title: "New Session", tint: .purple) {
|
|
| 162 |
+ VStack(alignment: .leading, spacing: 12) {
|
|
| 163 |
+ if liveMeterSummaries.isEmpty {
|
|
| 164 |
+ Text("Connect a live meter first to start a consumption monitor session.")
|
|
| 165 |
+ .font(.footnote) |
|
| 166 |
+ .foregroundColor(.secondary) |
|
| 167 |
+ } else {
|
|
| 168 |
+ Text("Device")
|
|
| 169 |
+ .font(.subheadline.weight(.semibold)) |
|
| 170 |
+ |
|
| 171 |
+ if availableDevices.isEmpty {
|
|
| 172 |
+ Text("No devices available. Add a device in the sidebar first.")
|
|
| 173 |
+ .font(.caption) |
|
| 174 |
+ .foregroundColor(.secondary) |
|
| 175 |
+ } else {
|
|
| 176 |
+ Picker("Device", selection: $selectedDeviceID) {
|
|
| 177 |
+ Text("Select Device").tag(Optional<UUID>.none)
|
|
| 178 |
+ ForEach(availableDevices) { device in
|
|
| 179 |
+ Text(device.name).tag(Optional(device.id)) |
|
| 180 |
+ } |
|
| 181 |
+ } |
|
| 182 |
+ .pickerStyle(.menu) |
|
| 183 |
+ } |
|
| 184 |
+ |
|
| 185 |
+ Text("Meter")
|
|
| 186 |
+ .font(.subheadline.weight(.semibold)) |
|
| 187 |
+ |
|
| 188 |
+ Picker("Meter", selection: $selectedMeterMACAddress) {
|
|
| 189 |
+ Text("Select Meter").tag(Optional<String>.none)
|
|
| 190 |
+ ForEach(liveMeterSummaries) { summary in
|
|
| 191 |
+ Text(summary.displayName).tag(Optional(summary.macAddress)) |
|
| 192 |
+ } |
|
| 193 |
+ } |
|
| 194 |
+ .pickerStyle(.menu) |
|
| 195 |
+ |
|
| 196 |
+ Button("Start Session") {
|
|
| 197 |
+ startSession() |
|
| 198 |
+ } |
|
| 199 |
+ .disabled(!canStart) |
|
| 200 |
+ .buttonStyle(.borderedProminent) |
|
| 201 |
+ .tint(.purple) |
|
| 202 |
+ } |
|
| 203 |
+ } |
|
| 204 |
+ |
|
| 205 |
+ if activeSession == nil, selectedDevice != nil, selectedMeterSummary == nil {
|
|
| 206 |
+ Text("Select a meter to begin.")
|
|
| 207 |
+ .font(.caption) |
|
| 208 |
+ .foregroundColor(.secondary) |
|
| 209 |
+ } else if activeSession == nil, selectedMeterSummary != nil, selectedDevice == nil {
|
|
| 210 |
+ Text("Select the device you want to monitor.")
|
|
| 211 |
+ .font(.caption) |
|
| 212 |
+ .foregroundColor(.secondary) |
|
| 213 |
+ } else if activeSession == nil, canStart {
|
|
| 214 |
+ Text("Samples are recorded every 60 seconds at a rate of 60 per hour.")
|
|
| 215 |
+ .font(.caption) |
|
| 216 |
+ .foregroundColor(.secondary) |
|
| 217 |
+ } |
|
| 218 |
+ } |
|
| 219 |
+ } |
|
| 220 |
+ |
|
| 221 |
+ // MARK: - Active Session Card |
|
| 222 |
+ |
|
| 223 |
+ private func activeSessionCard(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
| 224 |
+ MeterInfoCardView( |
|
| 225 |
+ title: "Session Running", |
|
| 226 |
+ infoMessage: "Samples are stored every 60 seconds. The session persists across app restarts until you stop it.", |
|
| 227 |
+ tint: .purple |
|
| 228 |
+ ) {
|
|
| 229 |
+ if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
|
|
| 230 |
+ MeterInfoRowView(label: "Device", value: device.name) |
|
| 231 |
+ } |
|
| 232 |
+ if let summary = liveMeterSummaries.first(where: { $0.macAddress == session.meterMACAddress }) {
|
|
| 233 |
+ MeterInfoRowView(label: "Meter", value: summary.displayName) |
|
| 234 |
+ } |
|
| 235 |
+ MeterInfoRowView(label: "Duration", value: formattedDuration(session.elapsedDuration)) |
|
| 236 |
+ MeterInfoRowView(label: "Samples", value: "\(session.committedSampleCount) × 60 s") |
|
| 237 |
+ MeterInfoRowView(label: "Total Energy", value: energyLabel(session.cumulativeEnergyWh)) |
|
| 238 |
+ |
|
| 239 |
+ HStack(spacing: 12) {
|
|
| 240 |
+ Button("Save & Stop") {
|
|
| 241 |
+ _ = appData.stopConsumptionMonitor(for: session.meterMACAddress, save: true) |
|
| 242 |
+ } |
|
| 243 |
+ .disabled(session.committedSampleCount == 0) |
|
| 244 |
+ |
|
| 245 |
+ Button("Discard") {
|
|
| 246 |
+ discardConfirmationVisibility = true |
|
| 247 |
+ } |
|
| 248 |
+ .foregroundColor(.red) |
|
| 249 |
+ } |
|
| 250 |
+ .buttonStyle(.borderedProminent) |
|
| 251 |
+ .tint(.purple) |
|
| 252 |
+ } |
|
| 253 |
+ } |
|
| 254 |
+ |
|
| 255 |
+ // MARK: - Live Metrics Card |
|
| 256 |
+ |
|
| 257 |
+ private func liveMetricsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
| 258 |
+ VStack(spacing: 18) {
|
|
| 259 |
+ MeterInfoCardView(title: "Live Reading", tint: .indigo) {
|
|
| 260 |
+ MeterInfoRowView(label: "Power", value: "\(session.currentPowerWatts.format(decimalDigits: 3)) W") |
|
| 261 |
+ MeterInfoRowView(label: "Current", value: "\(session.currentCurrentAmps.format(decimalDigits: 3)) A") |
|
| 262 |
+ MeterInfoRowView(label: "Voltage", value: "\(session.currentVoltageVolts.format(decimalDigits: 3)) V") |
|
| 263 |
+ } |
|
| 264 |
+ |
|
| 265 |
+ if session.committedSamples.count >= 2 {
|
|
| 266 |
+ liveChartCard(session.committedSamples) |
|
| 267 |
+ } |
|
| 268 |
+ |
|
| 269 |
+ if session.cumulativeEnergyWh > 0 {
|
|
| 270 |
+ projectionCard( |
|
| 271 |
+ averagePowerWatts: session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001), |
|
| 272 |
+ totalEnergyWh: session.cumulativeEnergyWh |
|
| 273 |
+ ) |
|
| 274 |
+ } |
|
| 275 |
+ } |
|
| 276 |
+ } |
|
| 277 |
+ |
|
| 278 |
+ @ViewBuilder |
|
| 279 |
+ private func liveChartCard(_ samples: [ConsumptionMonitorSample]) -> some View {
|
|
| 280 |
+ if #available(iOS 16, *) {
|
|
| 281 |
+ MeterInfoCardView(title: "Power Over Time", tint: .purple) {
|
|
| 282 |
+ consumptionChart(samples: samples, tint: .purple) |
|
| 283 |
+ } |
|
| 284 |
+ } |
|
| 285 |
+ } |
|
| 286 |
+ |
|
| 287 |
+ // MARK: - Projections Card |
|
| 288 |
+ |
|
| 289 |
+ private func projectionCard(averagePowerWatts: Double, totalEnergyWh: Double) -> some View {
|
|
| 290 |
+ MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
|
|
| 291 |
+ MeterInfoRowView(label: "Average Power", value: "\(averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 292 |
+ MeterInfoRowView(label: "24 Hours", value: energyLabel(averagePowerWatts * 24)) |
|
| 293 |
+ MeterInfoRowView(label: "7 Days", value: energyLabel(averagePowerWatts * 24 * 7)) |
|
| 294 |
+ MeterInfoRowView(label: "30 Days", value: energyLabel(averagePowerWatts * 24 * 30)) |
|
| 295 |
+ MeterInfoRowView(label: "1 Year", value: energyLabel(averagePowerWatts * 24 * 365)) |
|
| 296 |
+ } |
|
| 297 |
+ } |
|
| 298 |
+ |
|
| 299 |
+ // MARK: - Saved Sessions List |
|
| 300 |
+ |
|
| 301 |
+ @ViewBuilder |
|
| 302 |
+ private var savedSessionsList: some View {
|
|
| 303 |
+ if !savedSessions.isEmpty {
|
|
| 304 |
+ MeterInfoCardView(title: "Saved Sessions", tint: .purple) {
|
|
| 305 |
+ ForEach(savedSessions) { session in
|
|
| 306 |
+ NavigationLink(destination: ConsumptionSessionDetailView(session: session)) {
|
|
| 307 |
+ HStack {
|
|
| 308 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 309 |
+ Text(session.startedAt, style: .date) |
|
| 310 |
+ .font(.subheadline.weight(.semibold)) |
|
| 311 |
+ Text("\(formattedDuration(session.duration)) · \(energyLabel(session.totalEnergyWh)) total")
|
|
| 312 |
+ .font(.caption) |
|
| 313 |
+ .foregroundColor(.secondary) |
|
| 314 |
+ } |
|
| 315 |
+ Spacer() |
|
| 316 |
+ Image(systemName: "chevron.right") |
|
| 317 |
+ .font(.caption.weight(.semibold)) |
|
| 318 |
+ .foregroundColor(.secondary) |
|
| 319 |
+ } |
|
| 320 |
+ .padding(.vertical, 4) |
|
| 321 |
+ } |
|
| 322 |
+ .buttonStyle(.plain) |
|
| 323 |
+ } |
|
| 324 |
+ } |
|
| 325 |
+ } |
|
| 326 |
+ } |
|
| 327 |
+ |
|
| 328 |
+ // MARK: - Actions |
|
| 329 |
+ |
|
| 330 |
+ private func startSession() {
|
|
| 331 |
+ guard let deviceID = selectedDeviceID, |
|
| 332 |
+ let meterSummary = selectedMeterSummary, |
|
| 333 |
+ let meter = meterSummary.meter else { return }
|
|
| 334 |
+ _ = appData.startConsumptionMonitor(for: deviceID, on: meter) |
|
| 335 |
+ } |
|
| 336 |
+} |
|
| 337 |
+ |
|
| 338 |
+// MARK: - Session Detail |
|
| 339 |
+ |
|
| 340 |
+struct ConsumptionSessionDetailView: View {
|
|
| 341 |
+ @EnvironmentObject private var appData: AppData |
|
| 342 |
+ |
|
| 343 |
+ let session: ConsumptionMonitorSessionSummary |
|
| 344 |
+ |
|
| 345 |
+ @State private var deleteConfirmationVisibility = false |
|
| 346 |
+ |
|
| 347 |
+ var body: some View {
|
|
| 348 |
+ ScrollView {
|
|
| 349 |
+ VStack(spacing: 18) {
|
|
| 350 |
+ overviewCard |
|
| 351 |
+ if session.averagePowerWatts > 0 {
|
|
| 352 |
+ projectionCard |
|
| 353 |
+ } |
|
| 354 |
+ if session.samples.count >= 2 {
|
|
| 355 |
+ chartCard |
|
| 356 |
+ } |
|
| 357 |
+ statsCard |
|
| 358 |
+ } |
|
| 359 |
+ .padding() |
|
| 360 |
+ } |
|
| 361 |
+ .background( |
|
| 362 |
+ LinearGradient( |
|
| 363 |
+ colors: [.purple.opacity(0.14), Color.clear], |
|
| 364 |
+ startPoint: .topLeading, |
|
| 365 |
+ endPoint: .bottomTrailing |
|
| 366 |
+ ) |
|
| 367 |
+ .ignoresSafeArea() |
|
| 368 |
+ ) |
|
| 369 |
+ .navigationTitle("Consumption Session")
|
|
| 370 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 371 |
+ .toolbar {
|
|
| 372 |
+ ToolbarItem(placement: .destructiveAction) {
|
|
| 373 |
+ Button(role: .destructive) {
|
|
| 374 |
+ deleteConfirmationVisibility = true |
|
| 375 |
+ } label: {
|
|
| 376 |
+ Image(systemName: "trash") |
|
| 377 |
+ } |
|
| 378 |
+ } |
|
| 379 |
+ } |
|
| 380 |
+ .confirmationDialog("Delete this session?", isPresented: $deleteConfirmationVisibility, titleVisibility: .visible) {
|
|
| 381 |
+ Button("Delete", role: .destructive) {
|
|
| 382 |
+ _ = appData.deleteConsumptionSession(id: session.id, deviceID: session.chargedDeviceID) |
|
| 383 |
+ } |
|
| 384 |
+ Button("Cancel", role: .cancel) {}
|
|
| 385 |
+ } |
|
| 386 |
+ } |
|
| 387 |
+ |
|
| 388 |
+ // MARK: - Cards |
|
| 389 |
+ |
|
| 390 |
+ private var overviewCard: some View {
|
|
| 391 |
+ MeterInfoCardView(title: "Overview", tint: .purple) {
|
|
| 392 |
+ MeterInfoRowView(label: "Started", value: session.startedAt.formatted(date: .abbreviated, time: .shortened)) |
|
| 393 |
+ if let endedAt = session.endedAt {
|
|
| 394 |
+ MeterInfoRowView(label: "Ended", value: endedAt.formatted(date: .abbreviated, time: .shortened)) |
|
| 395 |
+ } |
|
| 396 |
+ MeterInfoRowView(label: "Duration", value: formattedDuration(session.duration)) |
|
| 397 |
+ MeterInfoRowView(label: "Samples", value: "\(session.sampleCount) × 60 s") |
|
| 398 |
+ MeterInfoRowView(label: "Total Energy", value: energyLabel(session.totalEnergyWh)) |
|
| 399 |
+ if let meterName = session.meterName {
|
|
| 400 |
+ MeterInfoRowView(label: "Meter", value: meterName) |
|
| 401 |
+ } |
|
| 402 |
+ } |
|
| 403 |
+ } |
|
| 404 |
+ |
|
| 405 |
+ private var projectionCard: some View {
|
|
| 406 |
+ MeterInfoCardView(title: "Consumption Projection", tint: .teal) {
|
|
| 407 |
+ MeterInfoRowView(label: "Average Power", value: "\(session.averagePowerWatts.format(decimalDigits: 3)) W") |
|
| 408 |
+ MeterInfoRowView(label: "24 Hours", value: energyLabel(session.projectedDailyEnergyWh)) |
|
| 409 |
+ MeterInfoRowView(label: "7 Days", value: energyLabel(session.projectedWeeklyEnergyWh)) |
|
| 410 |
+ MeterInfoRowView(label: "30 Days", value: energyLabel(session.projectedMonthlyEnergyWh)) |
|
| 411 |
+ MeterInfoRowView(label: "1 Year", value: energyLabel(session.projectedYearlyEnergyWh)) |
|
| 412 |
+ } |
|
| 413 |
+ } |
|
| 414 |
+ |
|
| 415 |
+ private var statsCard: some View {
|
|
| 416 |
+ MeterInfoCardView(title: "Statistics", tint: .indigo) {
|
|
| 417 |
+ MeterInfoRowView(label: "Min Power", value: "\(session.minimumPowerWatts.format(decimalDigits: 3)) W") |
|
| 418 |
+ MeterInfoRowView(label: "Max Power", value: "\(session.maximumPowerWatts.format(decimalDigits: 3)) W") |
|
| 419 |
+ MeterInfoRowView(label: "Avg Current", value: "\(session.averageCurrentAmps.format(decimalDigits: 3)) A") |
|
| 420 |
+ MeterInfoRowView(label: "Avg Voltage", value: "\(session.averageVoltageVolts.format(decimalDigits: 3)) V") |
|
| 421 |
+ } |
|
| 422 |
+ } |
|
| 423 |
+ |
|
| 424 |
+ @ViewBuilder |
|
| 425 |
+ private var chartCard: some View {
|
|
| 426 |
+ if #available(iOS 16, *) {
|
|
| 427 |
+ MeterInfoCardView(title: "Power Over Time", tint: .purple) {
|
|
| 428 |
+ consumptionChart(samples: session.samples, tint: .purple) |
|
| 429 |
+ } |
|
| 430 |
+ } |
|
| 431 |
+ } |
|
| 432 |
+} |
|
@@ -79,7 +79,15 @@ struct ChargeSessionDetailView: View {
|
||
| 79 | 79 |
} |
| 80 | 80 |
|
| 81 | 81 |
private var session: ChargeSessionSummary? {
|
| 82 |
- chargedDevice?.sessions.first(where: { $0.id == sessionID })
|
|
| 82 |
+ appData.chargeSessionSummary(id: sessionID) |
|
| 83 |
+ ?? chargedDevice?.sessions.first(where: { $0.id == sessionID })
|
|
| 84 |
+ } |
|
| 85 |
+ |
|
| 86 |
+ private var chargedPowerbank: PowerbankSummary? {
|
|
| 87 |
+ guard let powerbankID = session?.chargedPowerbankID else {
|
|
| 88 |
+ return nil |
|
| 89 |
+ } |
|
| 90 |
+ return appData.powerbankSummaries.first { $0.id == powerbankID }
|
|
| 83 | 91 |
} |
| 84 | 92 |
|
| 85 | 93 |
private var liveMonitoringMeter: Meter? {
|
@@ -118,6 +126,8 @@ struct ChargeSessionDetailView: View {
|
||
| 118 | 126 |
Group {
|
| 119 | 127 |
if let chargedDevice, let session {
|
| 120 | 128 |
content(chargedDevice: chargedDevice, session: session) |
| 129 |
+ } else if let chargedPowerbank, let session {
|
|
| 130 |
+ powerbankContent(powerbank: chargedPowerbank, session: session) |
|
| 121 | 131 |
} else {
|
| 122 | 132 |
unavailableState |
| 123 | 133 |
} |
@@ -275,6 +285,104 @@ struct ChargeSessionDetailView: View {
|
||
| 275 | 285 |
.navigationBarTitleDisplayMode(.inline) |
| 276 | 286 |
} |
| 277 | 287 |
|
| 288 |
+ private func powerbankContent( |
|
| 289 |
+ powerbank: PowerbankSummary, |
|
| 290 |
+ session: ChargeSessionSummary |
|
| 291 |
+ ) -> some View {
|
|
| 292 |
+ ScrollView {
|
|
| 293 |
+ VStack(spacing: 16) {
|
|
| 294 |
+ powerbankSessionCard(powerbank: powerbank, session: session) |
|
| 295 |
+ |
|
| 296 |
+ if shouldShowSessionChart(session) {
|
|
| 297 |
+ powerbankChartCard(session) |
|
| 298 |
+ } |
|
| 299 |
+ |
|
| 300 |
+ if session.status.isOpen && !hasMonitoringControls {
|
|
| 301 |
+ followerNoticeCard(session) |
|
| 302 |
+ } |
|
| 303 |
+ } |
|
| 304 |
+ .padding(presentation == .embedded ? 16 : 20) |
|
| 305 |
+ } |
|
| 306 |
+ .background( |
|
| 307 |
+ LinearGradient( |
|
| 308 |
+ colors: [statusTint(for: session).opacity(0.14), Color.clear], |
|
| 309 |
+ startPoint: .topLeading, |
|
| 310 |
+ endPoint: .bottomTrailing |
|
| 311 |
+ ) |
|
| 312 |
+ .ignoresSafeArea() |
|
| 313 |
+ ) |
|
| 314 |
+ .navigationTitle(session.status.isOpen ? "Current Session" : "Session Details") |
|
| 315 |
+ .navigationBarTitleDisplayMode(.inline) |
|
| 316 |
+ .toolbar {
|
|
| 317 |
+ ToolbarItemGroup(placement: .primaryAction) {
|
|
| 318 |
+ if session.status.isOpen == false {
|
|
| 319 |
+ Button(role: .destructive) {
|
|
| 320 |
+ pendingSessionDeletion = session |
|
| 321 |
+ } label: {
|
|
| 322 |
+ Image(systemName: "trash") |
|
| 323 |
+ } |
|
| 324 |
+ .help("Delete session")
|
|
| 325 |
+ } |
|
| 326 |
+ } |
|
| 327 |
+ } |
|
| 328 |
+ } |
|
| 329 |
+ |
|
| 330 |
+ private func powerbankSessionCard( |
|
| 331 |
+ powerbank: PowerbankSummary, |
|
| 332 |
+ session: ChargeSessionSummary |
|
| 333 |
+ ) -> some View {
|
|
| 334 |
+ let displayedEnergyWh = displayedSessionEnergyWh(for: session) |
|
| 335 |
+ |
|
| 336 |
+ return VStack(alignment: .leading, spacing: 14) {
|
|
| 337 |
+ HStack {
|
|
| 338 |
+ Label(powerbank.name, systemImage: powerbank.identitySymbolName) |
|
| 339 |
+ .font(.headline) |
|
| 340 |
+ |
|
| 341 |
+ Spacer() |
|
| 342 |
+ |
|
| 343 |
+ Text(session.status.title) |
|
| 344 |
+ .font(.caption.weight(.bold)) |
|
| 345 |
+ .foregroundColor(monitoringStatusColor(for: session)) |
|
| 346 |
+ .padding(.horizontal, 8) |
|
| 347 |
+ .padding(.vertical, 4) |
|
| 348 |
+ .meterCard(tint: monitoringStatusColor(for: session), fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 349 |
+ } |
|
| 350 |
+ |
|
| 351 |
+ LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
|
| 352 |
+ metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
|
| 353 |
+ metricCell(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)), tint: .teal) |
|
| 354 |
+ metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) |
|
| 355 |
+ metricCell(label: "Auto Stop", value: autoStopLabel(for: session), tint: .secondary) |
|
| 356 |
+ } |
|
| 357 |
+ |
|
| 358 |
+ if let sourcePowerbankID = session.sourcePowerbankID, |
|
| 359 |
+ let sourcePowerbank = appData.powerbankSummaries.first(where: { $0.id == sourcePowerbankID }) {
|
|
| 360 |
+ MeterInfoRowView(label: "Source Powerbank", value: sourcePowerbank.name) |
|
| 361 |
+ } |
|
| 362 |
+ |
|
| 363 |
+ BatteryCheckpointSectionView( |
|
| 364 |
+ sessionID: session.id, |
|
| 365 |
+ checkpoints: session.checkpoints, |
|
| 366 |
+ message: "Checkpoints are stored on the powerbank charge session and help estimate received capacity.", |
|
| 367 |
+ canAddCheckpoint: hasMonitoringControls && appData.canAddBatteryCheckpoint(to: session.id), |
|
| 368 |
+ canDeleteCheckpoint: hasMonitoringControls || session.status.isOpen == false, |
|
| 369 |
+ requirementMessage: session.status.isOpen ? appData.batteryCheckpointCaptureRequirementMessage(for: session.id) : nil, |
|
| 370 |
+ effectiveEnergyWhOverride: displayedEnergyWh, |
|
| 371 |
+ onDelete: { checkpoint in
|
|
| 372 |
+ pendingCheckpointDeletion = checkpoint |
|
| 373 |
+ } |
|
| 374 |
+ ) |
|
| 375 |
+ |
|
| 376 |
+ if showingStopConfirm {
|
|
| 377 |
+ stopConfirmPanel(session: session, displayedEnergyWh: displayedEnergyWh) |
|
| 378 |
+ } else if hasMonitoringControls {
|
|
| 379 |
+ monitoringActionRow(session) |
|
| 380 |
+ } |
|
| 381 |
+ } |
|
| 382 |
+ .padding(18) |
|
| 383 |
+ .meterCard(tint: .green, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 384 |
+ } |
|
| 385 |
+ |
|
| 278 | 386 |
private func monitoringSessionCard( |
| 279 | 387 |
_ session: ChargeSessionSummary, |
| 280 | 388 |
chargedDevice: ChargedDeviceSummary |
@@ -1368,6 +1476,33 @@ struct ChargeSessionDetailView: View {
|
||
| 1368 | 1476 |
) |
| 1369 | 1477 |
} |
| 1370 | 1478 |
|
| 1479 |
+ private func powerbankChartCard(_ session: ChargeSessionSummary) -> some View {
|
|
| 1480 |
+ ChargeSessionChartCardView( |
|
| 1481 |
+ session: session, |
|
| 1482 |
+ monitoringMeter: liveMonitoringMeter, |
|
| 1483 |
+ batteryPercentPoints: batteryPercentChartPoints(forPowerbankSession: session), |
|
| 1484 |
+ controlMode: chartControlMode(for: session), |
|
| 1485 |
+ onSetTrim: { start, end in
|
|
| 1486 |
+ setSessionTrim(sessionID: session.id, start: start, end: end) |
|
| 1487 |
+ }, |
|
| 1488 |
+ onStopWithTrim: { start, end in
|
|
| 1489 |
+ requestStop( |
|
| 1490 |
+ session, |
|
| 1491 |
+ applyingTrimStart: start, |
|
| 1492 |
+ trimEnd: end, |
|
| 1493 |
+ title: "Trim End & Finish", |
|
| 1494 |
+ confirmTitle: "Finish", |
|
| 1495 |
+ explanation: "The selected chart window will be saved as this session's active charging window before the session is closed." |
|
| 1496 |
+ ) |
|
| 1497 |
+ }, |
|
| 1498 |
+ onCommitTrim: (session.status.isOpen == false && session.isTrimmed) |
|
| 1499 |
+ ? {
|
|
| 1500 |
+ pendingTrimCommitSession = session |
|
| 1501 |
+ } |
|
| 1502 |
+ : nil |
|
| 1503 |
+ ) |
|
| 1504 |
+ } |
|
| 1505 |
+ |
|
| 1371 | 1506 |
private func chartControlMode(for session: ChargeSessionSummary) -> ChargeSessionChartControlMode {
|
| 1372 | 1507 |
if hasMonitoringControls {
|
| 1373 | 1508 |
return .activeMonitoring |
@@ -1478,6 +1613,22 @@ struct ChargeSessionDetailView: View {
|
||
| 1478 | 1613 |
return points |
| 1479 | 1614 |
} |
| 1480 | 1615 |
|
| 1616 |
+ private func batteryPercentChartPoints( |
|
| 1617 |
+ forPowerbankSession session: ChargeSessionSummary |
|
| 1618 |
+ ) -> [Measurements.Measurement.Point] {
|
|
| 1619 |
+ session.checkpoints |
|
| 1620 |
+ .filter { session.effectiveTimeRange.contains($0.timestamp) }
|
|
| 1621 |
+ .sorted { $0.timestamp < $1.timestamp }
|
|
| 1622 |
+ .enumerated() |
|
| 1623 |
+ .map { index, checkpoint in
|
|
| 1624 |
+ Measurements.Measurement.Point( |
|
| 1625 |
+ id: index, |
|
| 1626 |
+ timestamp: checkpoint.timestamp, |
|
| 1627 |
+ value: min(max(checkpoint.batteryPercent, 0), 100) |
|
| 1628 |
+ ) |
|
| 1629 |
+ } |
|
| 1630 |
+ } |
|
| 1631 |
+ |
|
| 1481 | 1632 |
private func coalescedBatteryPercentCandidates( |
| 1482 | 1633 |
_ candidates: [BatteryPercentCandidate] |
| 1483 | 1634 |
) -> [BatteryPercentCandidate] {
|
@@ -46,11 +46,22 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 46 | 46 |
return appData.powerbankSummaries.first { $0.id == powerbankID }
|
| 47 | 47 |
} |
| 48 | 48 |
|
| 49 |
+ private var chargedPowerbank: PowerbankSummary? {
|
|
| 50 |
+ guard let session = appData.chargeSessionSummary(id: sessionID), |
|
| 51 |
+ let powerbankID = session.chargedPowerbankID else {
|
|
| 52 |
+ return nil |
|
| 53 |
+ } |
|
| 54 |
+ return appData.powerbankSummaries.first { $0.id == powerbankID }
|
|
| 55 |
+ } |
|
| 56 |
+ |
|
| 49 | 57 |
private var allowsSubjectToggle: Bool {
|
| 50 |
- sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true |
|
| 58 |
+ chargedPowerbank == nil && sourcePowerbank?.batteryLevelReporting.allowsCheckpoints == true |
|
| 51 | 59 |
} |
| 52 | 60 |
|
| 53 | 61 |
private var activeReporting: BatteryLevelReporting {
|
| 62 |
+ if let chargedPowerbank {
|
|
| 63 |
+ return chargedPowerbank.batteryLevelReporting |
|
| 64 |
+ } |
|
| 54 | 65 |
if subject == .powerbank, let sourcePowerbank {
|
| 55 | 66 |
return sourcePowerbank.batteryLevelReporting |
| 56 | 67 |
} |
@@ -58,7 +69,7 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 58 | 69 |
} |
| 59 | 70 |
|
| 60 | 71 |
private var activeBarsCount: Int {
|
| 61 |
- max(1, sourcePowerbank?.batteryBarsCount ?? 1) |
|
| 72 |
+ max(1, (chargedPowerbank ?? sourcePowerbank)?.batteryBarsCount ?? 1) |
|
| 62 | 73 |
} |
| 63 | 74 |
|
| 64 | 75 |
private var plausibilityWarning: BatteryCheckpointPlausibilityWarning? {
|
@@ -117,6 +128,11 @@ struct BatteryCheckpointEditorContentView: View {
|
||
| 117 | 128 |
|
| 118 | 129 |
compactEditorRow |
| 119 | 130 |
} |
| 131 |
+ .onAppear {
|
|
| 132 |
+ if chargedPowerbank != nil {
|
|
| 133 |
+ subject = .powerbank |
|
| 134 |
+ } |
|
| 135 |
+ } |
|
| 120 | 136 |
} |
| 121 | 137 |
|
| 122 | 138 |
@ViewBuilder |
@@ -35,6 +35,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 35 | 35 |
private enum ActiveMode: Hashable {
|
| 36 | 36 |
case chargeSession |
| 37 | 37 |
case standbyPower |
| 38 |
+ case consumptionMonitor |
|
| 38 | 39 |
} |
| 39 | 40 |
|
| 40 | 41 |
private enum SessionStartRequirement: Identifiable {
|
@@ -81,8 +82,11 @@ struct MeterChargeRecordContentView: View {
|
||
| 81 | 82 |
@State private var showsMeterTotalsInfo = false |
| 82 | 83 |
@State private var activeMode: ActiveMode = .chargeSession |
| 83 | 84 |
@State private var draftChargedDeviceID: UUID? |
| 85 |
+ @State private var draftChargedPowerbankID: UUID? |
|
| 84 | 86 |
@State private var draftChargerID: UUID? |
| 85 | 87 |
@State private var draftSourcePowerbankID: UUID? |
| 88 |
+ @State private var draftConsumptionDeviceID: UUID? |
|
| 89 |
+ @State private var discardConsumptionConfirmation = false |
|
| 86 | 90 |
|
| 87 | 91 |
var body: some View {
|
| 88 | 92 |
Group {
|
@@ -93,6 +97,8 @@ struct MeterChargeRecordContentView: View {
|
||
| 93 | 97 |
monitoringMeter: usbMeter, |
| 94 | 98 |
presentation: .embedded |
| 95 | 99 |
) |
| 100 |
+ } else if activeMode == .consumptionMonitor, let session = activeConsumptionSession {
|
|
| 101 |
+ consumptionSessionActiveView(session) |
|
| 96 | 102 |
} else {
|
| 97 | 103 |
ScrollView {
|
| 98 | 104 |
VStack(spacing: 14) {
|
@@ -105,6 +111,8 @@ struct MeterChargeRecordContentView: View {
|
||
| 105 | 111 |
chargeSessionSetupCard |
| 106 | 112 |
case .standbyPower: |
| 107 | 113 |
standbyPowerCard |
| 114 |
+ case .consumptionMonitor: |
|
| 115 |
+ consumptionMonitorSetupCard |
|
| 108 | 116 |
} |
| 109 | 117 |
} |
| 110 | 118 |
.padding() |
@@ -119,10 +127,22 @@ struct MeterChargeRecordContentView: View {
|
||
| 119 | 127 |
) |
| 120 | 128 |
.ignoresSafeArea() |
| 121 | 129 |
) |
| 130 |
+ .confirmationDialog( |
|
| 131 |
+ "Stop and discard this session?", |
|
| 132 |
+ isPresented: $discardConsumptionConfirmation, |
|
| 133 |
+ titleVisibility: .visible |
|
| 134 |
+ ) {
|
|
| 135 |
+ Button("Discard", role: .destructive) {
|
|
| 136 |
+ _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: false) |
|
| 137 |
+ } |
|
| 138 |
+ Button("Cancel", role: .cancel) {}
|
|
| 139 |
+ } message: {
|
|
| 140 |
+ Text("The current session data will be lost and nothing will be saved.")
|
|
| 141 |
+ } |
|
| 122 | 142 |
.onAppear {
|
| 123 | 143 |
syncDraftSelections() |
| 124 | 144 |
} |
| 125 |
- .onChange(of: selectedChargedDevice?.id) { _ in
|
|
| 145 |
+ .onChange(of: selectedChargeTargetID) { _ in
|
|
| 126 | 146 |
syncDraftSelections() |
| 127 | 147 |
} |
| 128 | 148 |
.onChange(of: openChargeSession?.id) { _ in
|
@@ -141,6 +161,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 141 | 161 |
return appData.chargedDeviceSummary(id: openChargeSession.chargedDeviceID) |
| 142 | 162 |
} |
| 143 | 163 |
|
| 164 |
+ guard draftChargedPowerbankID == nil else { return nil }
|
|
| 144 | 165 |
guard let draftChargedDeviceID else { return nil }
|
| 145 | 166 |
let chargedDevice = appData.chargedDeviceSummary(id: draftChargedDeviceID) |
| 146 | 167 |
return chargedDevice?.isCharger == false ? chargedDevice : nil |
@@ -150,14 +171,51 @@ struct MeterChargeRecordContentView: View {
|
||
| 150 | 171 |
appData.deviceSummaries |
| 151 | 172 |
} |
| 152 | 173 |
|
| 153 |
- private var selectedChargedDeviceID: Binding<UUID?> {
|
|
| 174 |
+ private var selectedChargedPowerbank: PowerbankSummary? {
|
|
| 175 |
+ if let openChargeSession, |
|
| 176 |
+ let powerbankID = openChargeSession.chargedPowerbankID {
|
|
| 177 |
+ return appData.powerbankSummaries.first { $0.id == powerbankID }
|
|
| 178 |
+ } |
|
| 179 |
+ |
|
| 180 |
+ guard let draftChargedPowerbankID else { return nil }
|
|
| 181 |
+ return appData.powerbankSummaries.first { $0.id == draftChargedPowerbankID }
|
|
| 182 |
+ } |
|
| 183 |
+ |
|
| 184 |
+ private var selectedChargeTargetID: UUID? {
|
|
| 185 |
+ selectedChargedPowerbank?.id ?? selectedChargedDevice?.id |
|
| 186 |
+ } |
|
| 187 |
+ |
|
| 188 |
+ private var selectedChargeTargetTag: Binding<String> {
|
|
| 154 | 189 |
Binding( |
| 155 |
- get: { openChargeSession?.chargedDeviceID ?? draftChargedDeviceID },
|
|
| 190 |
+ get: {
|
|
| 191 |
+ if let openChargeSession {
|
|
| 192 |
+ if let powerbankID = openChargeSession.chargedPowerbankID {
|
|
| 193 |
+ return "powerbank:\(powerbankID.uuidString)" |
|
| 194 |
+ } |
|
| 195 |
+ return "device:\(openChargeSession.chargedDeviceID.uuidString)" |
|
| 196 |
+ } |
|
| 197 |
+ if let draftChargedPowerbankID {
|
|
| 198 |
+ return "powerbank:\(draftChargedPowerbankID.uuidString)" |
|
| 199 |
+ } |
|
| 200 |
+ if let draftChargedDeviceID {
|
|
| 201 |
+ return "device:\(draftChargedDeviceID.uuidString)" |
|
| 202 |
+ } |
|
| 203 |
+ return "none" |
|
| 204 |
+ }, |
|
| 156 | 205 |
set: { newValue in
|
| 157 |
- draftChargedDeviceID = newValue |
|
| 158 |
- if newValue == nil {
|
|
| 206 |
+ if newValue == "none" {
|
|
| 207 |
+ draftChargedDeviceID = nil |
|
| 208 |
+ draftChargedPowerbankID = nil |
|
| 159 | 209 |
draftChargingTransportMode = nil |
| 160 | 210 |
draftChargingStateMode = nil |
| 211 |
+ } else if newValue.hasPrefix("device:"),
|
|
| 212 |
+ let uuid = UUID(uuidString: String(newValue.dropFirst("device:".count))) {
|
|
| 213 |
+ draftChargedDeviceID = uuid |
|
| 214 |
+ draftChargedPowerbankID = nil |
|
| 215 |
+ } else if newValue.hasPrefix("powerbank:"),
|
|
| 216 |
+ let uuid = UUID(uuidString: String(newValue.dropFirst("powerbank:".count))) {
|
|
| 217 |
+ draftChargedDeviceID = nil |
|
| 218 |
+ draftChargedPowerbankID = uuid |
|
| 161 | 219 |
} |
| 162 | 220 |
} |
| 163 | 221 |
) |
@@ -182,13 +240,17 @@ struct MeterChargeRecordContentView: View {
|
||
| 182 | 240 |
appData.powerbankSummaries |
| 183 | 241 |
} |
| 184 | 242 |
|
| 243 |
+ private var availableSourcePowerbanks: [PowerbankSummary] {
|
|
| 244 |
+ availablePowerbanks.filter { $0.id != selectedChargedPowerbank?.id }
|
|
| 245 |
+ } |
|
| 246 |
+ |
|
| 185 | 247 |
private var selectedSourcePowerbank: PowerbankSummary? {
|
| 186 | 248 |
if let openChargeSession, |
| 187 | 249 |
let powerbankID = openChargeSession.sourcePowerbankID {
|
| 188 | 250 |
return availablePowerbanks.first { $0.id == powerbankID }
|
| 189 | 251 |
} |
| 190 | 252 |
guard let draftSourcePowerbankID else { return nil }
|
| 191 |
- return availablePowerbanks.first { $0.id == draftSourcePowerbankID }
|
|
| 253 |
+ return availableSourcePowerbanks.first { $0.id == draftSourcePowerbankID }
|
|
| 192 | 254 |
} |
| 193 | 255 |
|
| 194 | 256 |
/// Unified source selection encoding — packed into a String tag because SwiftUI Picker |
@@ -223,7 +285,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 223 | 285 |
} |
| 224 | 286 |
|
| 225 | 287 |
private var hasAnySource: Bool {
|
| 226 |
- availableChargers.isEmpty == false || availablePowerbanks.isEmpty == false |
|
| 288 |
+ availableChargers.isEmpty == false || availableSourcePowerbanks.isEmpty == false |
|
| 227 | 289 |
} |
| 228 | 290 |
|
| 229 | 291 |
private var selectedChargerID: Binding<UUID?> {
|
@@ -239,6 +301,15 @@ struct MeterChargeRecordContentView: View {
|
||
| 239 | 301 |
appData.activeChargeSessionSummary(for: meterMACAddress) |
| 240 | 302 |
} |
| 241 | 303 |
|
| 304 |
+ private var activeConsumptionSession: ConsumptionMonitorLiveSession? {
|
|
| 305 |
+ appData.consumptionMonitorSession(for: meterMACAddress) |
|
| 306 |
+ } |
|
| 307 |
+ |
|
| 308 |
+ private var draftConsumptionDevice: ChargedDeviceSummary? {
|
|
| 309 |
+ guard let id = draftConsumptionDeviceID else { return nil }
|
|
| 310 |
+ return availableChargedDevices.first { $0.id == id }
|
|
| 311 |
+ } |
|
| 312 |
+ |
|
| 242 | 313 |
private var showsMeterTotalsCard: Bool {
|
| 243 | 314 |
usbMeter.supportsRecordingView |
| 244 | 315 |
|| usbMeter.supportsDataGroupCommands |
@@ -287,6 +358,17 @@ struct MeterChargeRecordContentView: View {
|
||
| 287 | 358 |
requirements.append(.existingSession) |
| 288 | 359 |
} |
| 289 | 360 |
|
| 361 |
+ if selectedChargedPowerbank != nil {
|
|
| 362 |
+ if shouldRequireInitialCheckpoint {
|
|
| 363 |
+ if hasInitialCheckpointInput == false {
|
|
| 364 |
+ requirements.append(.initialCheckpointEmpty) |
|
| 365 |
+ } else if initialCheckpointValue == nil {
|
|
| 366 |
+ requirements.append(.initialCheckpointInvalid) |
|
| 367 |
+ } |
|
| 368 |
+ } |
|
| 369 |
+ return requirements |
|
| 370 |
+ } |
|
| 371 |
+ |
|
| 290 | 372 |
guard let selectedChargedDevice else {
|
| 291 | 373 |
requirements.append(.device) |
| 292 | 374 |
return requirements |
@@ -357,7 +439,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 357 | 439 |
if showsWirelessChargerSection {
|
| 358 | 440 |
return hasAnySource |
| 359 | 441 |
} |
| 360 |
- return availablePowerbanks.isEmpty == false |
|
| 442 |
+ return availableSourcePowerbanks.isEmpty == false |
|
| 361 | 443 |
} |
| 362 | 444 |
|
| 363 | 445 |
private var sourceSectionListsChargers: Bool {
|
@@ -370,7 +452,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 370 | 452 |
? "No source available" |
| 371 | 453 |
: "Choose source" |
| 372 | 454 |
} |
| 373 |
- return availablePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)" |
|
| 455 |
+ return availableSourcePowerbanks.isEmpty ? "No powerbank available" : "Choose powerbank (optional)" |
|
| 374 | 456 |
} |
| 375 | 457 |
|
| 376 | 458 |
// MARK: - Status Header |
@@ -400,6 +482,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 400 | 482 |
Picker("", selection: $activeMode) {
|
| 401 | 483 |
Label("Charge Session", systemImage: "bolt.fill").tag(ActiveMode.chargeSession)
|
| 402 | 484 |
Label("Standby Power", systemImage: "powersleep").tag(ActiveMode.standbyPower)
|
| 485 |
+ Label("Consumption", systemImage: "chart.line.uptrend.xyaxis").tag(ActiveMode.consumptionMonitor)
|
|
| 403 | 486 |
} |
| 404 | 487 |
.pickerStyle(.segmented) |
| 405 | 488 |
.labelsHidden() |
@@ -411,18 +494,24 @@ struct MeterChargeRecordContentView: View {
|
||
| 411 | 494 |
VStack(alignment: .leading, spacing: 0) {
|
| 412 | 495 |
// Device |
| 413 | 496 |
setupRow(icon: "iphone", iconColor: .blue) {
|
| 414 |
- Picker(selection: selectedChargedDeviceID) {
|
|
| 415 |
- Text("Choose device").tag(UUID?.none)
|
|
| 497 |
+ Picker(selection: selectedChargeTargetTag) {
|
|
| 498 |
+ Text("Choose target").tag("none")
|
|
| 416 | 499 |
ForEach(availableChargedDevices) { device in
|
| 417 |
- Text(device.name).tag(Optional(device.id)) |
|
| 500 |
+ Text(device.name).tag("device:\(device.id.uuidString)")
|
|
| 501 |
+ } |
|
| 502 |
+ ForEach(availablePowerbanks) { powerbank in
|
|
| 503 |
+ Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
|
|
| 418 | 504 |
} |
| 419 | 505 |
} label: {
|
| 420 | 506 |
HStack(spacing: 8) {
|
| 421 | 507 |
if let device = selectedChargedDevice {
|
| 422 | 508 |
ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) |
| 423 | 509 |
.font(.subheadline.weight(.semibold)) |
| 510 |
+ } else if let powerbank = selectedChargedPowerbank {
|
|
| 511 |
+ Label(powerbank.name, systemImage: powerbank.identitySymbolName) |
|
| 512 |
+ .font(.subheadline.weight(.semibold)) |
|
| 424 | 513 |
} else {
|
| 425 |
- Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") |
|
| 514 |
+ Text(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty ? "No targets available" : "Choose target") |
|
| 426 | 515 |
.foregroundColor(.secondary) |
| 427 | 516 |
.font(.subheadline) |
| 428 | 517 |
} |
@@ -433,7 +522,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 433 | 522 |
} |
| 434 | 523 |
} |
| 435 | 524 |
.pickerStyle(.menu) |
| 436 |
- .disabled(availableChargedDevices.isEmpty) |
|
| 525 |
+ .disabled(availableChargedDevices.isEmpty && availablePowerbanks.isEmpty) |
|
| 437 | 526 |
} |
| 438 | 527 |
|
| 439 | 528 |
// Charging type — only when device supports multiple |
@@ -469,7 +558,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 469 | 558 |
Text("Charger · \(charger.name)").tag("charger:\(charger.id.uuidString)")
|
| 470 | 559 |
} |
| 471 | 560 |
} |
| 472 |
- ForEach(availablePowerbanks) { powerbank in
|
|
| 561 |
+ ForEach(availableSourcePowerbanks) { powerbank in
|
|
| 473 | 562 |
Text("Powerbank · \(powerbank.name)").tag("powerbank:\(powerbank.id.uuidString)")
|
| 474 | 563 |
} |
| 475 | 564 |
} label: {
|
@@ -593,6 +682,203 @@ struct MeterChargeRecordContentView: View {
|
||
| 593 | 682 |
.meterCard(tint: .orange, fillOpacity: 0.14, strokeOpacity: 0.20) |
| 594 | 683 |
} |
| 595 | 684 |
|
| 685 |
+ // MARK: - Consumption Monitor |
|
| 686 |
+ |
|
| 687 |
+ private var consumptionMonitorSetupCard: some View {
|
|
| 688 |
+ VStack(alignment: .leading, spacing: 0) {
|
|
| 689 |
+ setupRow(icon: "iphone", iconColor: .purple) {
|
|
| 690 |
+ Picker(selection: $draftConsumptionDeviceID) {
|
|
| 691 |
+ Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") |
|
| 692 |
+ .tag(Optional<UUID>.none) |
|
| 693 |
+ ForEach(availableChargedDevices) { device in
|
|
| 694 |
+ Text(device.name).tag(Optional(device.id)) |
|
| 695 |
+ } |
|
| 696 |
+ } label: {
|
|
| 697 |
+ HStack(spacing: 8) {
|
|
| 698 |
+ if let device = draftConsumptionDevice {
|
|
| 699 |
+ ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) |
|
| 700 |
+ .font(.subheadline.weight(.semibold)) |
|
| 701 |
+ } else {
|
|
| 702 |
+ Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") |
|
| 703 |
+ .foregroundColor(.secondary) |
|
| 704 |
+ .font(.subheadline) |
|
| 705 |
+ } |
|
| 706 |
+ Spacer(minLength: 8) |
|
| 707 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 708 |
+ .font(.caption.weight(.semibold)) |
|
| 709 |
+ .foregroundColor(.secondary) |
|
| 710 |
+ } |
|
| 711 |
+ } |
|
| 712 |
+ .pickerStyle(.menu) |
|
| 713 |
+ .disabled(availableChargedDevices.isEmpty) |
|
| 714 |
+ } |
|
| 715 |
+ |
|
| 716 |
+ Divider() |
|
| 717 |
+ Button("Start Session") {
|
|
| 718 |
+ startConsumptionSession() |
|
| 719 |
+ } |
|
| 720 |
+ .frame(maxWidth: .infinity) |
|
| 721 |
+ .padding(.vertical, 11) |
|
| 722 |
+ .font(.subheadline.weight(.semibold)) |
|
| 723 |
+ .foregroundColor(draftConsumptionDeviceID != nil ? .purple : .secondary) |
|
| 724 |
+ .buttonStyle(.plain) |
|
| 725 |
+ .disabled(draftConsumptionDeviceID == nil) |
|
| 726 |
+ } |
|
| 727 |
+ .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 728 |
+ } |
|
| 729 |
+ |
|
| 730 |
+ private func consumptionSessionActiveView(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
| 731 |
+ ScrollView {
|
|
| 732 |
+ VStack(spacing: 14) {
|
|
| 733 |
+ consumptionSessionHeaderCard |
|
| 734 |
+ liveMeterStripView |
|
| 735 |
+ consumptionSessionInfoCard(session) |
|
| 736 |
+ if session.cumulativeEnergyWh > 0 {
|
|
| 737 |
+ consumptionProjectionsCard(session) |
|
| 738 |
+ } |
|
| 739 |
+ } |
|
| 740 |
+ .padding() |
|
| 741 |
+ } |
|
| 742 |
+ } |
|
| 743 |
+ |
|
| 744 |
+ private var consumptionSessionHeaderCard: some View {
|
|
| 745 |
+ HStack {
|
|
| 746 |
+ Image(systemName: "chart.line.uptrend.xyaxis") |
|
| 747 |
+ .foregroundColor(.purple) |
|
| 748 |
+ Text("Consumption Monitor")
|
|
| 749 |
+ .font(.system(.title3, design: .rounded).weight(.bold)) |
|
| 750 |
+ Spacer() |
|
| 751 |
+ Text("Running")
|
|
| 752 |
+ .font(.caption.weight(.bold)) |
|
| 753 |
+ .foregroundColor(.green) |
|
| 754 |
+ .padding(.horizontal, 10) |
|
| 755 |
+ .padding(.vertical, 6) |
|
| 756 |
+ .meterCard(tint: .green, fillOpacity: 0.18, strokeOpacity: 0.24, cornerRadius: 999) |
|
| 757 |
+ } |
|
| 758 |
+ .padding(.horizontal, 18) |
|
| 759 |
+ .padding(.vertical, 12) |
|
| 760 |
+ .meterCard(tint: .purple, fillOpacity: 0.18, strokeOpacity: 0.24) |
|
| 761 |
+ } |
|
| 762 |
+ |
|
| 763 |
+ private func consumptionSessionInfoCard(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
| 764 |
+ VStack(alignment: .leading, spacing: 0) {
|
|
| 765 |
+ if let device = appData.chargedDeviceSummary(id: session.chargedDeviceID) {
|
|
| 766 |
+ setupRow(icon: "iphone", iconColor: .purple) {
|
|
| 767 |
+ ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) |
|
| 768 |
+ .font(.subheadline.weight(.semibold)) |
|
| 769 |
+ Spacer() |
|
| 770 |
+ } |
|
| 771 |
+ Divider().padding(.leading, 46) |
|
| 772 |
+ } |
|
| 773 |
+ |
|
| 774 |
+ setupRow(icon: "clock", iconColor: .secondary) {
|
|
| 775 |
+ Text("Duration")
|
|
| 776 |
+ .foregroundColor(.secondary) |
|
| 777 |
+ .font(.subheadline) |
|
| 778 |
+ Spacer() |
|
| 779 |
+ Text(consumptionDurationText(session.elapsedDuration)) |
|
| 780 |
+ .font(.subheadline.weight(.semibold)) |
|
| 781 |
+ .monospacedDigit() |
|
| 782 |
+ } |
|
| 783 |
+ |
|
| 784 |
+ Divider().padding(.leading, 46) |
|
| 785 |
+ |
|
| 786 |
+ setupRow(icon: "waveform", iconColor: .secondary) {
|
|
| 787 |
+ Text("Samples")
|
|
| 788 |
+ .foregroundColor(.secondary) |
|
| 789 |
+ .font(.subheadline) |
|
| 790 |
+ Spacer() |
|
| 791 |
+ Text("\(session.committedSampleCount) × 60 s")
|
|
| 792 |
+ .font(.subheadline.weight(.semibold)) |
|
| 793 |
+ .monospacedDigit() |
|
| 794 |
+ } |
|
| 795 |
+ |
|
| 796 |
+ Divider().padding(.leading, 46) |
|
| 797 |
+ |
|
| 798 |
+ setupRow(icon: "bolt.fill", iconColor: .yellow) {
|
|
| 799 |
+ Text("Energy")
|
|
| 800 |
+ .foregroundColor(.secondary) |
|
| 801 |
+ .font(.subheadline) |
|
| 802 |
+ Spacer() |
|
| 803 |
+ Text(consumptionEnergyText(session.cumulativeEnergyWh)) |
|
| 804 |
+ .font(.subheadline.weight(.semibold)) |
|
| 805 |
+ .monospacedDigit() |
|
| 806 |
+ } |
|
| 807 |
+ |
|
| 808 |
+ Divider() |
|
| 809 |
+ |
|
| 810 |
+ HStack(spacing: 0) {
|
|
| 811 |
+ Button("Save & Stop") {
|
|
| 812 |
+ _ = appData.stopConsumptionMonitor(for: meterMACAddress, save: true) |
|
| 813 |
+ } |
|
| 814 |
+ .frame(maxWidth: .infinity) |
|
| 815 |
+ .padding(.vertical, 11) |
|
| 816 |
+ .font(.subheadline.weight(.semibold)) |
|
| 817 |
+ .foregroundColor(session.committedSampleCount > 0 ? .green : .secondary) |
|
| 818 |
+ .buttonStyle(.plain) |
|
| 819 |
+ .disabled(session.committedSampleCount == 0) |
|
| 820 |
+ |
|
| 821 |
+ Divider().frame(height: 42) |
|
| 822 |
+ |
|
| 823 |
+ Button("Discard") {
|
|
| 824 |
+ discardConsumptionConfirmation = true |
|
| 825 |
+ } |
|
| 826 |
+ .frame(maxWidth: .infinity) |
|
| 827 |
+ .padding(.vertical, 11) |
|
| 828 |
+ .font(.subheadline.weight(.semibold)) |
|
| 829 |
+ .foregroundColor(.red) |
|
| 830 |
+ .buttonStyle(.plain) |
|
| 831 |
+ } |
|
| 832 |
+ } |
|
| 833 |
+ .meterCard(tint: .purple, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 834 |
+ } |
|
| 835 |
+ |
|
| 836 |
+ private func consumptionProjectionsCard(_ session: ConsumptionMonitorLiveSession) -> some View {
|
|
| 837 |
+ let avgPower = session.cumulativeEnergyWh / max(session.elapsedDuration / 3600, 0.001) |
|
| 838 |
+ return VStack(alignment: .leading, spacing: 0) {
|
|
| 839 |
+ setupRow(icon: "chart.bar.fill", iconColor: .teal) {
|
|
| 840 |
+ Text("Avg Power")
|
|
| 841 |
+ .foregroundColor(.secondary) |
|
| 842 |
+ .font(.subheadline) |
|
| 843 |
+ Spacer() |
|
| 844 |
+ Text("\(avgPower.format(decimalDigits: 3)) W")
|
|
| 845 |
+ .font(.subheadline.weight(.semibold)) |
|
| 846 |
+ .monospacedDigit() |
|
| 847 |
+ } |
|
| 848 |
+ Divider().padding(.leading, 46) |
|
| 849 |
+ setupRow(icon: "calendar.day.timeline.right", iconColor: .teal) {
|
|
| 850 |
+ Text("24 Hours")
|
|
| 851 |
+ .foregroundColor(.secondary) |
|
| 852 |
+ .font(.subheadline) |
|
| 853 |
+ Spacer() |
|
| 854 |
+ Text(consumptionEnergyText(avgPower * 24)) |
|
| 855 |
+ .font(.subheadline.weight(.semibold)) |
|
| 856 |
+ .monospacedDigit() |
|
| 857 |
+ } |
|
| 858 |
+ Divider().padding(.leading, 46) |
|
| 859 |
+ setupRow(icon: "calendar", iconColor: .teal) {
|
|
| 860 |
+ Text("30 Days")
|
|
| 861 |
+ .foregroundColor(.secondary) |
|
| 862 |
+ .font(.subheadline) |
|
| 863 |
+ Spacer() |
|
| 864 |
+ Text(consumptionEnergyText(avgPower * 24 * 30)) |
|
| 865 |
+ .font(.subheadline.weight(.semibold)) |
|
| 866 |
+ .monospacedDigit() |
|
| 867 |
+ } |
|
| 868 |
+ Divider().padding(.leading, 46) |
|
| 869 |
+ setupRow(icon: "calendar", iconColor: .teal) {
|
|
| 870 |
+ Text("1 Year")
|
|
| 871 |
+ .foregroundColor(.secondary) |
|
| 872 |
+ .font(.subheadline) |
|
| 873 |
+ Spacer() |
|
| 874 |
+ Text(consumptionEnergyText(avgPower * 24 * 365)) |
|
| 875 |
+ .font(.subheadline.weight(.semibold)) |
|
| 876 |
+ .monospacedDigit() |
|
| 877 |
+ } |
|
| 878 |
+ } |
|
| 879 |
+ .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20) |
|
| 880 |
+ } |
|
| 881 |
+ |
|
| 596 | 882 |
// MARK: - Standby Power Card |
| 597 | 883 |
|
| 598 | 884 |
private var standbyPowerCard: some View {
|
@@ -731,6 +1017,22 @@ struct MeterChargeRecordContentView: View {
|
||
| 731 | 1017 |
} |
| 732 | 1018 |
|
| 733 | 1019 |
private func startSession() {
|
| 1020 |
+ if let selectedChargedPowerbank {
|
|
| 1021 |
+ let didStart = appData.startPowerbankChargeSession( |
|
| 1022 |
+ for: usbMeter, |
|
| 1023 |
+ powerbankID: selectedChargedPowerbank.id, |
|
| 1024 |
+ sourcePowerbankID: selectedSourcePowerbank?.id, |
|
| 1025 |
+ initialBatteryPercent: initialCheckpointMode == .known ? initialCheckpointValue : nil, |
|
| 1026 |
+ startsFromFlatBattery: initialCheckpointMode == .flat |
|
| 1027 |
+ ) |
|
| 1028 |
+ |
|
| 1029 |
+ if didStart {
|
|
| 1030 |
+ initialCheckpoint = "" |
|
| 1031 |
+ initialCheckpointMode = .known |
|
| 1032 |
+ } |
|
| 1033 |
+ return |
|
| 1034 |
+ } |
|
| 1035 |
+ |
|
| 734 | 1036 |
guard let selectedChargedDevice, |
| 735 | 1037 |
let chargingTransportMode = selectedDraftTransportMode, |
| 736 | 1038 |
let chargingStateMode = selectedDraftChargingStateMode else {
|
@@ -757,6 +1059,11 @@ struct MeterChargeRecordContentView: View {
|
||
| 757 | 1059 |
} |
| 758 | 1060 |
} |
| 759 | 1061 |
|
| 1062 |
+ private func startConsumptionSession() {
|
|
| 1063 |
+ guard let deviceID = draftConsumptionDeviceID else { return }
|
|
| 1064 |
+ _ = appData.startConsumptionMonitor(for: deviceID, on: usbMeter) |
|
| 1065 |
+ } |
|
| 1066 |
+ |
|
| 760 | 1067 |
private func adjustInitialCheckpoint(by delta: Double) {
|
| 761 | 1068 |
guard initialCheckpointMode == .known else { return }
|
| 762 | 1069 |
let currentValue = initialCheckpointValue ?? 0 |
@@ -765,6 +1072,15 @@ struct MeterChargeRecordContentView: View {
|
||
| 765 | 1072 |
} |
| 766 | 1073 |
|
| 767 | 1074 |
private func syncDraftSelections() {
|
| 1075 |
+ if selectedChargedPowerbank != nil {
|
|
| 1076 |
+ draftChargingTransportMode = .wired |
|
| 1077 |
+ draftChargingStateMode = .on |
|
| 1078 |
+ if draftSourcePowerbankID == selectedChargedPowerbank?.id {
|
|
| 1079 |
+ draftSourcePowerbankID = nil |
|
| 1080 |
+ } |
|
| 1081 |
+ return |
|
| 1082 |
+ } |
|
| 1083 |
+ |
|
| 768 | 1084 |
guard let selectedChargedDevice else {
|
| 769 | 1085 |
draftChargingTransportMode = nil |
| 770 | 1086 |
draftChargingStateMode = nil |
@@ -838,4 +1154,18 @@ struct MeterChargeRecordContentView: View {
|
||
| 838 | 1154 |
} |
| 839 | 1155 |
.buttonStyle(.plain) |
| 840 | 1156 |
} |
| 1157 |
+ |
|
| 1158 |
+ private func consumptionDurationText(_ duration: TimeInterval) -> String {
|
|
| 1159 |
+ let formatter = DateComponentsFormatter() |
|
| 1160 |
+ formatter.allowedUnits = duration >= 3600 ? [.hour, .minute] : [.minute, .second] |
|
| 1161 |
+ formatter.unitsStyle = .abbreviated |
|
| 1162 |
+ formatter.zeroFormattingBehavior = .pad |
|
| 1163 |
+ return formatter.string(from: max(duration, 0)) ?? "0m" |
|
| 1164 |
+ } |
|
| 1165 |
+ |
|
| 1166 |
+ private func consumptionEnergyText(_ wattHours: Double) -> String {
|
|
| 1167 |
+ wattHours >= 1000 |
|
| 1168 |
+ ? "\((wattHours / 1000).format(decimalDigits: 3)) kWh" |
|
| 1169 |
+ : "\(wattHours.format(decimalDigits: 2)) Wh" |
|
| 1170 |
+ } |
|
| 841 | 1171 |
} |