Showing 9 changed files with 1694 additions and 30 deletions
+8 -0
USB Meter.xcodeproj/project.pbxproj
@@ -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 */,
+143 -3
USB Meter/Model/AppData.swift
@@ -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
 
+94 -1
USB Meter/Model/ChargeInsightsModel.swift
@@ -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
 }
+146 -8
USB Meter/Model/ChargeInsightsStore.swift
@@ -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
+356 -0
USB Meter/Model/ConsumptionMonitorStore.swift
@@ -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
+}
+432 -0
USB Meter/Views/ChargedDevices/ConsumptionMonitorView.swift
@@ -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
+}
+152 -1
USB Meter/Views/ChargedDevices/Sessions/ChargeSessionDetailView.swift
@@ -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] {
+18 -2
USB Meter/Views/ChargedDevices/Sheets/ChargeSession/BatteryCheckpointEditorSheetView.swift
@@ -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
+345 -15
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -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
 }