Showing 8 changed files with 369 additions and 11 deletions
+45 -1
USB Meter/Model/AppData.swift
@@ -357,6 +357,8 @@ final class AppData : ObservableObject {
357 357
         initialBatteryPercent: Double?,
358 358
         startsFromFlatBattery: Bool
359 359
     ) -> Bool {
360
+        meter.resetMeterCountersForNewSession()
361
+
360 362
         guard let snapshot = meter.chargingMonitorSnapshot else {
361 363
             return false
362 364
         }
@@ -433,10 +435,18 @@ final class AppData : ObservableObject {
433 435
 
434 436
     @discardableResult
435 437
     func addBatteryCheckpoint(percent: Double, label: String?, for meter: Meter) -> Bool {
438
+        observeChargeSnapshot(from: meter)
439
+
440
+        let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description)
441
+        let checkpointEnergyWh = activeSession.map { displayedSessionEnergyWh(for: $0, on: meter) }
442
+        let checkpointChargeAh = activeSession.map { displayedSessionChargeAh(for: $0, on: meter) }
443
+
436 444
         let didSave = chargeInsightsStore?.addBatteryCheckpoint(
437 445
             percent: percent,
438 446
             label: label,
439
-            for: meter.btSerial.macAddress.description
447
+            for: meter.btSerial.macAddress.description,
448
+            measuredEnergyWh: checkpointEnergyWh,
449
+            measuredChargeAh: checkpointChargeAh
440 450
         ) ?? false
441 451
 
442 452
         if didSave {
@@ -746,6 +756,40 @@ final class AppData : ObservableObject {
746 756
             message: "This value is about \(gapText) percentage points \(direction) the predicted \(predictedText)% based on \(prediction.anchorDescription). Save it only if the device gauge really jumped that much."
747 757
         )
748 758
     }
759
+
760
+    private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
761
+        let storedEnergyWh = session.effectiveOrMeasuredEnergyWh
762
+        guard session.status.isOpen else {
763
+            return storedEnergyWh
764
+        }
765
+
766
+        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
767
+            return storedEnergyWh
768
+        }
769
+
770
+        if let baselineEnergyWh = session.meterEnergyBaselineWh {
771
+            return max(storedEnergyWh, max(meter.recordedWH - baselineEnergyWh, 0))
772
+        }
773
+
774
+        return storedEnergyWh
775
+    }
776
+
777
+    private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
778
+        let storedChargeAh = session.measuredChargeAh
779
+        guard session.status.isOpen else {
780
+            return storedChargeAh
781
+        }
782
+
783
+        guard session.meterMACAddress == meter.btSerial.macAddress.description else {
784
+            return storedChargeAh
785
+        }
786
+
787
+        if let baselineChargeAh = session.meterChargeBaselineAh {
788
+            return max(storedChargeAh, max(meter.recordedAH - baselineChargeAh, 0))
789
+        }
790
+
791
+        return storedChargeAh
792
+    }
749 793
 }
750 794
 
751 795
 extension AppData.MeterSummary {
+123 -0
USB Meter/Model/CKModel.xcdatamodeld/USB_Meter 11.xcdatamodel/contents
@@ -0,0 +1,123 @@
1
+<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
2
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23657" systemVersion="24G81" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES">
3
+    <entity name="ChargedDevice" representedClassName="ChargedDevice" syncable="YES" codeGenerationType="class">
4
+        <attribute name="id" optional="YES" attributeType="String"/>
5
+        <attribute name="name" optional="YES" attributeType="String"/>
6
+        <attribute name="deviceClassRawValue" optional="YES" attributeType="String"/>
7
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
8
+        <attribute name="chargingStateAvailabilityRawValue" optional="YES" attributeType="String"/>
9
+        <attribute name="supportsWiredCharging" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
10
+        <attribute name="supportsWirelessCharging" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
11
+        <attribute name="preferredChargingTransportRawValue" optional="YES" attributeType="String"/>
12
+        <attribute name="wirelessChargingProfileRawValue" optional="YES" attributeType="String"/>
13
+        <attribute name="configuredCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
14
+        <attribute name="learnedCompletionCurrentsRawValue" optional="YES" attributeType="String"/>
15
+        <attribute name="wiredChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
16
+        <attribute name="wirelessChargeCompletionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
17
+        <attribute name="minimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
18
+        <attribute name="estimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
19
+        <attribute name="wiredMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
20
+        <attribute name="wirelessMinimumCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
21
+        <attribute name="wiredEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
22
+        <attribute name="wirelessEstimatedBatteryCapacityWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
23
+        <attribute name="wirelessChargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
24
+        <attribute name="chargerObservedVoltageSelectionsRawValue" optional="YES" attributeType="String"/>
25
+        <attribute name="chargerIdleCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
26
+        <attribute name="chargerEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
27
+        <attribute name="chargerMaximumPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
28
+        <attribute name="qrIdentifier" optional="YES" attributeType="String"/>
29
+        <attribute name="lastAssociatedMeterMAC" optional="YES" attributeType="String"/>
30
+        <attribute name="notes" optional="YES" attributeType="String"/>
31
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
32
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
33
+    </entity>
34
+    <entity name="ChargeSession" representedClassName="ChargeSession" syncable="YES" codeGenerationType="class">
35
+        <attribute name="id" optional="YES" attributeType="String"/>
36
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
37
+        <attribute name="chargerID" optional="YES" attributeType="String"/>
38
+        <attribute name="meterMACAddress" optional="YES" attributeType="String"/>
39
+        <attribute name="meterName" optional="YES" attributeType="String"/>
40
+        <attribute name="meterModel" optional="YES" attributeType="String"/>
41
+        <attribute name="startedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
42
+        <attribute name="endedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
43
+        <attribute name="lastObservedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
44
+        <attribute name="pausedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
45
+        <attribute name="statusRawValue" optional="YES" attributeType="String"/>
46
+        <attribute name="sourceModeRawValue" optional="YES" attributeType="String"/>
47
+        <attribute name="chargingTransportRawValue" optional="YES" attributeType="String"/>
48
+        <attribute name="chargingStateRawValue" optional="YES" attributeType="String"/>
49
+        <attribute name="autoStopEnabled" optional="YES" attributeType="Boolean" defaultValueString="YES" usesScalarValueType="YES"/>
50
+        <attribute name="selectedDataGroup" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
51
+        <attribute name="meterEnergyBaselineWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
52
+        <attribute name="meterChargeBaselineAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
53
+        <attribute name="meterDurationBaselineSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
54
+        <attribute name="meterLastEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
55
+        <attribute name="meterLastChargeAh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
56
+        <attribute name="meterLastDurationSeconds" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
57
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
58
+        <attribute name="effectiveBatteryEnergyWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
59
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
60
+        <attribute name="minimumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
61
+        <attribute name="maximumObservedCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
62
+        <attribute name="maximumObservedPowerWatts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
63
+        <attribute name="maximumObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
64
+        <attribute name="selectedSourceVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
65
+        <attribute name="completionCurrentAmps" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
66
+        <attribute name="stopThresholdAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
67
+        <attribute name="startBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
68
+        <attribute name="endBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
69
+        <attribute name="capacityEstimateWh" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
70
+        <attribute name="wirelessEfficiencyFactor" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
71
+        <attribute name="usesEstimatedWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
72
+        <attribute name="shouldWarnAboutLowWirelessEfficiency" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
73
+        <attribute name="supportsChargingWhileOff" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
74
+        <attribute name="usedOfflineMeterCounters" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
75
+        <attribute name="belowThresholdSince" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
76
+        <attribute name="lastObservedCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
77
+        <attribute name="lastObservedPowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
78
+        <attribute name="lastObservedVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
79
+        <attribute name="hasObservedChargeFlow" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
80
+        <attribute name="targetBatteryPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
81
+        <attribute name="targetBatteryAlertTriggeredAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
82
+        <attribute name="requiresCompletionConfirmation" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
83
+        <attribute name="completionConfirmationRequestedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
84
+        <attribute name="completionContradictionPercent" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
85
+        <attribute name="completionConfirmationCooldownUntil" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
86
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
87
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
88
+    </entity>
89
+    <entity name="ChargeCheckpoint" representedClassName="ChargeCheckpoint" syncable="YES" codeGenerationType="class">
90
+        <attribute name="id" optional="YES" attributeType="String"/>
91
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
92
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
93
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
94
+        <attribute name="batteryPercent" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
95
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
96
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
97
+        <attribute name="currentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
98
+        <attribute name="voltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
99
+        <attribute name="label" optional="YES" attributeType="String"/>
100
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
101
+    </entity>
102
+    <entity name="ChargeSessionSample" representedClassName="ChargeSessionSample" syncable="YES" codeGenerationType="class">
103
+        <attribute name="id" optional="YES" attributeType="String"/>
104
+        <attribute name="sessionID" optional="YES" attributeType="String"/>
105
+        <attribute name="chargedDeviceID" optional="YES" attributeType="String"/>
106
+        <attribute name="bucketIndex" optional="YES" attributeType="Integer 32" usesScalarValueType="YES"/>
107
+        <attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
108
+        <attribute name="averageCurrentAmps" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
109
+        <attribute name="averageVoltageVolts" optional="YES" attributeType="Double" usesScalarValueType="YES"/>
110
+        <attribute name="averagePowerWatts" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
111
+        <attribute name="measuredEnergyWh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
112
+        <attribute name="measuredChargeAh" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
113
+        <attribute name="sampleCount" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
114
+        <attribute name="createdAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
115
+        <attribute name="updatedAt" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
116
+    </entity>
117
+    <elements>
118
+        <element name="ChargedDevice" positionX="-72" positionY="-36" width="128" height="418"/>
119
+        <element name="ChargeSession" positionX="136" positionY="-36" width="128" height="838"/>
120
+        <element name="ChargeCheckpoint" positionX="344" positionY="-36" width="128" height="208"/>
121
+        <element name="ChargeSessionSample" positionX="552" positionY="-36" width="128" height="253"/>
122
+    </elements>
123
+</model>
+17 -0
USB Meter/Model/ChargeInsightsModel.swift
@@ -362,6 +362,8 @@ struct ChargeSessionSummary: Identifiable, Hashable {
362 362
     let measuredChargeAh: Double
363 363
     let meterEnergyBaselineWh: Double?
364 364
     let meterChargeBaselineAh: Double?
365
+    let meterDurationBaselineSeconds: Double?
366
+    let meterLastDurationSeconds: Double?
365 367
     let minimumObservedCurrentAmps: Double?
366 368
     let maximumObservedCurrentAmps: Double?
367 369
     let maximumObservedPowerWatts: Double?
@@ -397,6 +399,20 @@ struct ChargeSessionSummary: Identifiable, Hashable {
397 399
         (endedAt ?? lastObservedAt).timeIntervalSince(startedAt)
398 400
     }
399 401
 
402
+    var meterObservedDuration: TimeInterval? {
403
+        guard let meterDurationBaselineSeconds, let meterLastDurationSeconds else {
404
+            return nil
405
+        }
406
+        guard meterLastDurationSeconds >= meterDurationBaselineSeconds else {
407
+            return nil
408
+        }
409
+        return meterLastDurationSeconds - meterDurationBaselineSeconds
410
+    }
411
+
412
+    var effectiveDuration: TimeInterval {
413
+        meterObservedDuration ?? duration
414
+    }
415
+
400 416
     var effectiveOrMeasuredEnergyWh: Double {
401 417
         effectiveBatteryEnergyWh ?? measuredEnergyWh
402 418
     }
@@ -732,5 +748,6 @@ struct ChargingMonitorSnapshot {
732 748
     let selectedDataGroup: UInt8?
733 749
     let meterChargeCounterAh: Double?
734 750
     let meterEnergyCounterWh: Double?
751
+    let meterRecordingDurationSeconds: TimeInterval?
735 752
     let fallbackStopThresholdAmps: Double
736 753
 }
+42 -4
USB Meter/Model/ChargeInsightsStore.swift
@@ -552,7 +552,9 @@ final class ChargeInsightsStore {
552 552
     func addBatteryCheckpoint(
553 553
         percent: Double,
554 554
         label: String?,
555
-        for meterMACAddress: String
555
+        for meterMACAddress: String,
556
+        measuredEnergyWh: Double? = nil,
557
+        measuredChargeAh: Double? = nil
556 558
     ) -> Bool {
557 559
         guard percent.isFinite, percent >= 0, percent <= 100 else {
558 560
             return false
@@ -564,7 +566,13 @@ final class ChargeInsightsStore {
564 566
                 return
565 567
             }
566 568
 
567
-            didSave = addBatteryCheckpoint(percent: percent, label: label, to: session)
569
+            didSave = addBatteryCheckpoint(
570
+                percent: percent,
571
+                label: label,
572
+                measuredEnergyWh: measuredEnergyWh,
573
+                measuredChargeAh: measuredChargeAh,
574
+                to: session
575
+            )
568 576
         }
569 577
         return didSave
570 578
     }
@@ -1055,6 +1063,10 @@ final class ChargeInsightsStore {
1055 1063
             session.setValue(meterChargeCounterAh, forKey: "meterChargeBaselineAh")
1056 1064
             session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1057 1065
         }
1066
+        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1067
+            setValue(meterRecordingDurationSeconds, on: session, key: "meterDurationBaselineSeconds")
1068
+            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1069
+        }
1058 1070
         session.setValue(now, forKey: "createdAt")
1059 1071
         session.setValue(now, forKey: "updatedAt")
1060 1072
 
@@ -1141,6 +1153,14 @@ final class ChargeInsightsStore {
1141 1153
             session.setValue(meterChargeCounterAh, forKey: "meterLastChargeAh")
1142 1154
         }
1143 1155
 
1156
+        if let meterRecordingDurationSeconds = snapshot.meterRecordingDurationSeconds {
1157
+            let baselineDuration = optionalDoubleValue(session, key: "meterDurationBaselineSeconds") ?? meterRecordingDurationSeconds
1158
+            if optionalDoubleValue(session, key: "meterDurationBaselineSeconds") == nil {
1159
+                setValue(baselineDuration, on: session, key: "meterDurationBaselineSeconds")
1160
+            }
1161
+            setValue(meterRecordingDurationSeconds, on: session, key: "meterLastDurationSeconds")
1162
+        }
1163
+
1144 1164
         let existingMinimum = optionalDoubleValue(session, key: "minimumObservedCurrentAmps")
1145 1165
         let updatedMinimum: Double
1146 1166
         if snapshot.currentAmps > 0 {
@@ -1588,6 +1608,8 @@ final class ChargeInsightsStore {
1588 1608
         percent: Double,
1589 1609
         label: String?,
1590 1610
         timestamp: Date = Date(),
1611
+        measuredEnergyWhOverride: Double? = nil,
1612
+        measuredChargeAhOverride: Double? = nil,
1591 1613
         to session: NSManagedObject
1592 1614
     ) -> String? {
1593 1615
         guard
@@ -1599,15 +1621,18 @@ final class ChargeInsightsStore {
1599 1621
         }
1600 1622
 
1601 1623
         let checkpoint = NSManagedObject(entity: entity, insertInto: context)
1602
-        let checkpointEnergyWh = optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1624
+        let checkpointEnergyWh = measuredEnergyWhOverride
1625
+            ?? optionalDoubleValue(session, key: "effectiveBatteryEnergyWh")
1603 1626
             ?? doubleValue(session, key: "measuredEnergyWh")
1627
+        let checkpointChargeAh = measuredChargeAhOverride
1628
+            ?? doubleValue(session, key: "measuredChargeAh")
1604 1629
         checkpoint.setValue(UUID().uuidString, forKey: "id")
1605 1630
         checkpoint.setValue(sessionID, forKey: "sessionID")
1606 1631
         checkpoint.setValue(chargedDeviceID, forKey: "chargedDeviceID")
1607 1632
         checkpoint.setValue(timestamp, forKey: "timestamp")
1608 1633
         checkpoint.setValue(percent, forKey: "batteryPercent")
1609 1634
         checkpoint.setValue(checkpointEnergyWh, forKey: "measuredEnergyWh")
1610
-        checkpoint.setValue(doubleValue(session, key: "measuredChargeAh"), forKey: "measuredChargeAh")
1635
+        checkpoint.setValue(checkpointChargeAh, forKey: "measuredChargeAh")
1611 1636
         checkpoint.setValue(doubleValue(session, key: "lastObservedCurrentAmps"), forKey: "currentAmps")
1612 1637
         checkpoint.setValue(
1613 1638
             chargingTransportMode(for: session) == .wired ? optionalDoubleValue(session, key: "lastObservedVoltageVolts") : nil,
@@ -1652,13 +1677,24 @@ final class ChargeInsightsStore {
1652 1677
     private func addBatteryCheckpoint(
1653 1678
         percent: Double,
1654 1679
         label: String?,
1680
+        measuredEnergyWh: Double? = nil,
1681
+        measuredChargeAh: Double? = nil,
1655 1682
         to session: NSManagedObject,
1656 1683
         timestamp: Date = Date()
1657 1684
     ) -> Bool {
1685
+        if let measuredEnergyWh, measuredEnergyWh.isFinite {
1686
+            session.setValue(max(measuredEnergyWh, 0), forKey: "measuredEnergyWh")
1687
+        }
1688
+        if let measuredChargeAh, measuredChargeAh.isFinite {
1689
+            session.setValue(max(measuredChargeAh, 0), forKey: "measuredChargeAh")
1690
+        }
1691
+
1658 1692
         guard let chargedDeviceID = insertBatteryCheckpoint(
1659 1693
             percent: percent,
1660 1694
             label: label,
1661 1695
             timestamp: timestamp,
1696
+            measuredEnergyWhOverride: measuredEnergyWh,
1697
+            measuredChargeAhOverride: measuredChargeAh,
1662 1698
             to: session
1663 1699
         ) else {
1664 1700
             return false
@@ -1945,6 +1981,8 @@ final class ChargeInsightsStore {
1945 1981
             measuredChargeAh: doubleValue(object, key: "measuredChargeAh"),
1946 1982
             meterEnergyBaselineWh: optionalDoubleValue(object, key: "meterEnergyBaselineWh"),
1947 1983
             meterChargeBaselineAh: optionalDoubleValue(object, key: "meterChargeBaselineAh"),
1984
+            meterDurationBaselineSeconds: optionalDoubleValue(object, key: "meterDurationBaselineSeconds"),
1985
+            meterLastDurationSeconds: optionalDoubleValue(object, key: "meterLastDurationSeconds"),
1948 1986
             minimumObservedCurrentAmps: optionalDoubleValue(object, key: "minimumObservedCurrentAmps"),
1949 1987
             maximumObservedCurrentAmps: optionalDoubleValue(object, key: "maximumObservedCurrentAmps"),
1950 1988
             maximumObservedPowerWatts: optionalDoubleValue(object, key: "maximumObservedPowerWatts"),
+92 -0
USB Meter/Model/Measurements.swift
@@ -134,6 +134,19 @@ class Measurements : ObservableObject {
134 134
             self.objectWillChange.send()
135 135
         }
136 136
 
137
+        func replacePoints(_ points: [Point]) {
138
+            self.points = points.enumerated().map { index, point in
139
+                Point(
140
+                    id: index,
141
+                    timestamp: point.timestamp,
142
+                    value: point.value,
143
+                    kind: point.kind
144
+                )
145
+            }
146
+            rebuildContext()
147
+            self.objectWillChange.send()
148
+        }
149
+
137 150
         func trim(before cutoff: Date) {
138 151
             points = points
139 152
                 .filter { $0.timestamp >= cutoff }
@@ -298,6 +311,85 @@ class Measurements : ObservableObject {
298 311
         accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
299 312
     }
300 313
 
314
+    func restorePersistedChargeSessionSamplesIfNeeded(
315
+        from session: ChargeSessionSummary
316
+    ) {
317
+        guard power.points.isEmpty,
318
+              voltage.points.isEmpty,
319
+              current.points.isEmpty,
320
+              temperature.points.isEmpty,
321
+              energy.points.isEmpty,
322
+              rssi.points.isEmpty else {
323
+            return
324
+        }
325
+
326
+        let sortedSamples = session.aggregatedSamples.sorted { lhs, rhs in
327
+            if lhs.bucketIndex != rhs.bucketIndex {
328
+                return lhs.bucketIndex < rhs.bucketIndex
329
+            }
330
+            return lhs.timestamp < rhs.timestamp
331
+        }
332
+
333
+        guard !sortedSamples.isEmpty else { return }
334
+
335
+        resetPendingAggregation()
336
+
337
+        power.replacePoints(restoredPoints(from: sortedSamples) { sample in
338
+            sample.averagePowerWatts
339
+        })
340
+        current.replacePoints(restoredPoints(from: sortedSamples) { sample in
341
+            sample.averageCurrentAmps
342
+        })
343
+        voltage.replacePoints(restoredPoints(from: sortedSamples) { sample in
344
+            sample.averageVoltageVolts
345
+        })
346
+        energy.replacePoints(restoredPoints(from: sortedSamples) { sample in
347
+            sample.measuredEnergyWh
348
+        })
349
+        temperature.resetSeries()
350
+        rssi.resetSeries()
351
+        lastEnergyCounterValue = nil
352
+        lastEnergyGroupID = nil
353
+        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
354
+        self.objectWillChange.send()
355
+    }
356
+
357
+    private func restoredPoints(
358
+        from samples: [ChargeSessionSampleSummary],
359
+        value: (ChargeSessionSampleSummary) -> Double?
360
+    ) -> [Measurement.Point] {
361
+        var restored: [Measurement.Point] = []
362
+        var previousSample: ChargeSessionSampleSummary?
363
+
364
+        for sample in samples {
365
+            guard let pointValue = value(sample) else { continue }
366
+
367
+            if let previousSample,
368
+               sample.bucketIndex - previousSample.bucketIndex > 1 {
369
+                restored.append(
370
+                    Measurement.Point(
371
+                        id: restored.count,
372
+                        timestamp: sample.timestamp,
373
+                        value: restored.last?.value ?? pointValue,
374
+                        kind: .discontinuity
375
+                    )
376
+                )
377
+            }
378
+
379
+            restored.append(
380
+                Measurement.Point(
381
+                    id: restored.count,
382
+                    timestamp: sample.timestamp,
383
+                    value: pointValue,
384
+                    kind: .sample
385
+                )
386
+            )
387
+            previousSample = sample
388
+        }
389
+
390
+        return restored
391
+    }
392
+
301 393
     func resetSeries() {
302 394
         power.resetSeries()
303 395
         voltage.resetSeries()
+26 -1
USB Meter/Model/Meter.swift
@@ -529,6 +529,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
529 529
     private var pendingVolatileMemoryResetDeadline: Date?
530 530
     private var liveDataChanged = false
531 531
     private var restoredChargeSessionID: UUID?
532
+    private var lastRecorderObservationAt: Date?
532 533
         
533 534
     @discardableResult
534 535
     private func setIfChanged<T: Equatable>(_ keyPath: ReferenceWritableKeyPath<Meter, T>, to value: T) -> Bool {
@@ -635,10 +636,17 @@ class Meter : NSObject, ObservableObject, Identifiable {
635 636
             selectedDataGroup: usesNativeRecordingCounters ? nil : (currentEnergySample()?.groupID ?? currentChargeSample()?.groupID),
636 637
             meterChargeCounterAh: nativeChargeCounter ?? currentChargeSample()?.value,
637 638
             meterEnergyCounterWh: nativeEnergyCounter ?? currentEnergySample()?.value,
639
+            meterRecordingDurationSeconds: usesNativeRecordingCounters ? TimeInterval(recordingDuration) : nil,
638 640
             fallbackStopThresholdAmps: supportsRecordingThreshold ? recordingTreshold : chargeRecordStopThreshold
639 641
         )
640 642
     }
641 643
 
644
+    var recordingBootedAt: Date? {
645
+        guard supportsRecordingView else { return nil }
646
+        guard let lastRecorderObservationAt else { return nil }
647
+        return lastRecorderObservationAt.addingTimeInterval(-TimeInterval(recordingDuration))
648
+    }
649
+
642 650
     var chargingMonitorSnapshot: ChargingMonitorSnapshot? {
643 651
         chargingMonitorSnapshot(at: Date())
644 652
     }
@@ -827,6 +835,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
827 835
 
828 836
     private func apply(umSnapshot snapshot: UMSnapshot) {
829 837
         let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
838
+        lastRecorderObservationAt = dataDumpRequestTimestamp
830 839
         setIfChanged(\.modelNumber, to: snapshot.modelNumber)
831 840
         setIfChanged(\.voltage, to: snapshot.voltage)
832 841
         setIfChanged(\.current, to: snapshot.current)
@@ -972,10 +981,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
972 981
         guard chargeRecordStartTimestamp == nil else { return }
973 982
         guard chargeRecordAH == 0, chargeRecordWH == 0, chargeRecordDuration == 0 else { return }
974 983
 
984
+        measurements.restorePersistedChargeSessionSamplesIfNeeded(from: activeSession)
975 985
         chargeRecordState = .active
976 986
         chargeRecordAH = activeSession.measuredChargeAh
977 987
         chargeRecordWH = activeSession.measuredEnergyWh
978
-        chargeRecordDuration = max(activeSession.lastObservedAt.timeIntervalSince(activeSession.startedAt), 0)
988
+        chargeRecordDuration = max(activeSession.effectiveDuration, 0)
979 989
         chargeRecordStopThreshold = activeSession.stopThresholdAmps
980 990
         chargeRecordStartTimestamp = activeSession.startedAt
981 991
         chargeRecordEndTimestamp = activeSession.lastObservedAt
@@ -1044,6 +1054,21 @@ class Meter : NSObject, ObservableObject, Identifiable {
1044 1054
         noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
1045 1055
         commandQueue.append(UMProtocol.clearCurrentGroup)
1046 1056
     }
1057
+
1058
+    func resetMeterCountersForNewSession() {
1059
+        guard supportsDataGroupCommands else { return }
1060
+
1061
+        clear()
1062
+
1063
+        if let record = dataGroupRecords[Int(selectedDataGroup)] {
1064
+            record.ah = 0
1065
+            record.wh = 0
1066
+        }
1067
+        recordedAH = 0
1068
+        recordedWH = 0
1069
+        recording = false
1070
+        objectWillChange.send()
1071
+    }
1047 1072
     
1048 1073
     func clear(group id: UInt8) {
1049 1074
         guard supportsDataGroupCommands else { return }
+3 -2
USB Meter/Views/ChargedDevices/ChargedDeviceDetailView.swift
@@ -687,10 +687,11 @@ struct ChargedDeviceDetailView: View {
687 687
 
688 688
     private func sessionDurationText(_ session: ChargeSessionSummary) -> String {
689 689
         let formatter = DateComponentsFormatter()
690
-        formatter.allowedUnits = session.duration >= 3600 ? [.hour, .minute] : [.minute, .second]
690
+        let effectiveDuration = max(session.effectiveDuration, 0)
691
+        formatter.allowedUnits = effectiveDuration >= 3600 ? [.hour, .minute] : [.minute, .second]
691 692
         formatter.unitsStyle = .abbreviated
692 693
         formatter.zeroFormattingBehavior = .dropAll
693
-        return formatter.string(from: max(session.duration, 0)) ?? "0m"
694
+        return formatter.string(from: effectiveDuration) ?? "0m"
694 695
     }
695 696
 
696 697
     private func statusTint(for session: ChargeSessionSummary) -> Color {
+21 -3
USB Meter/Views/Meter/Tabs/ChargeRecord/MeterChargeRecordTabView.swift
@@ -656,7 +656,6 @@ struct MeterChargeRecordContentView: View {
656 656
 
657 657
     private func chargingMonitorCard(_ openChargeSession: ChargeSessionSummary) -> some View {
658 658
         let displayedEnergyWh = displayedSessionEnergyWh(for: openChargeSession)
659
-
660 659
         return VStack(alignment: .leading, spacing: 12) {
661 660
             HStack(spacing: 8) {
662 661
                 Text("Charging Monitor")
@@ -668,11 +667,12 @@ struct MeterChargeRecordContentView: View {
668 667
             }
669 668
 
670 669
             ChargeRecordMetricsTableView(
671
-                labels: ["Type", "Mode", "Energy", "Auto Stop"],
670
+                labels: ["Type", "Mode", "Energy", "Duration", "Auto Stop"],
672 671
                 values: [
673 672
                     openChargeSession.chargingTransportMode.title,
674 673
                     openChargeSession.chargingStateMode.title,
675 674
                     "\(displayedEnergyWh.format(decimalDigits: 3)) Wh",
675
+                    formatDuration(max(openChargeSession.effectiveDuration, 0)),
676 676
                     autoStopLabel(for: openChargeSession)
677 677
                 ]
678 678
             )
@@ -892,7 +892,7 @@ struct MeterChargeRecordContentView: View {
892 892
     }
893 893
 
894 894
     private var meterTotalsCard: some View {
895
-        VStack(alignment: .leading, spacing: 12) {
895
+        return VStack(alignment: .leading, spacing: 12) {
896 896
             HStack(spacing: 8) {
897 897
                 Text("Meter Recorder")
898 898
                     .font(.headline)
@@ -929,6 +929,12 @@ struct MeterChargeRecordContentView: View {
929 929
                     usbMeter.supportsRecordingThreshold ? "\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A" : "Read-only"
930 930
                 ]
931 931
             )
932
+
933
+            if let recordingBootedAt = usbMeter.recordingBootedAt {
934
+                Text("Recorder uptime suggests the meter booted at \(recordingBootedAt.format()).")
935
+                    .font(.caption)
936
+                    .foregroundColor(.secondary)
937
+            }
932 938
         }
933 939
         .padding(18)
934 940
         .meterCard(tint: .teal, fillOpacity: 0.14, strokeOpacity: 0.20)
@@ -969,6 +975,18 @@ struct MeterChargeRecordContentView: View {
969 975
         return storedEnergyWh
970 976
     }
971 977
 
978
+    private func formatDuration(_ duration: TimeInterval) -> String {
979
+        let totalSeconds = Int(duration.rounded(.down))
980
+        let hours = totalSeconds / 3600
981
+        let minutes = (totalSeconds % 3600) / 60
982
+        let seconds = totalSeconds % 60
983
+
984
+        if hours > 0 {
985
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
986
+        }
987
+        return String(format: "%02d:%02d", minutes, seconds)
988
+    }
989
+
972 990
     private func sessionWarning(for session: ChargeSessionSummary) -> String? {
973 991
         guard session.chargingTransportMode == .wireless,
974 992
               let chargerID = session.chargerID,