Showing 3 changed files with 146 additions and 18 deletions
+12 -0
USB Meter/Model/AppData.swift
@@ -278,6 +278,11 @@ final class AppData : ObservableObject {
278 278
     func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
279 279
         let normalizedMAC = Self.normalizedMACAddress(meterMACAddress)
280 280
 
281
+        if expireOverlongChargeSessionsIfNeeded() {
282
+            reloadChargedDevices()
283
+            return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC)
284
+        }
285
+
281 286
         if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
282 287
             return cachedSummary
283 288
         }
@@ -1012,6 +1017,11 @@ final class AppData : ObservableObject {
1012 1017
             .first(where: { $0.status.isOpen && $0.meterMACAddress == normalizedMAC })
1013 1018
     }
1014 1019
 
1020
+    @discardableResult
1021
+    private func expireOverlongChargeSessionsIfNeeded(referenceDate: Date = Date()) -> Bool {
1022
+        chargeInsightsStore?.completeExpiredOpenSessions(referenceDate: referenceDate) ?? false
1023
+    }
1024
+
1015 1025
     private func reloadChargedDevices() {
1016 1026
         if Thread.isMainThread == false {
1017 1027
             DispatchQueue.main.async { [weak self] in
@@ -1023,6 +1033,8 @@ final class AppData : ObservableObject {
1023 1033
         pendingChargedDevicesReloadWorkItem?.cancel()
1024 1034
         pendingChargedDevicesReloadWorkItem = nil
1025 1035
 
1036
+        _ = expireOverlongChargeSessionsIfNeeded()
1037
+
1026 1038
         guard chargedDevicesReloadInFlight == false else {
1027 1039
             chargedDevicesReloadPending = true
1028 1040
             return
+133 -16
USB Meter/Model/ChargeInsightsStore.swift
@@ -30,6 +30,7 @@ final class ChargeInsightsStore {
30 30
         }
31 31
     }
32 32
 
33
+    private static let maximumChargeSessionDuration: TimeInterval = 12 * 60 * 60
33 34
     private static let persistedSamplesPerHour = 360
34 35
     private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour)
35 36
 
@@ -74,6 +75,50 @@ final class ChargeInsightsStore {
74 75
         return didSave
75 76
     }
76 77
 
78
+    @discardableResult
79
+    func completeExpiredOpenSessions(referenceDate: Date = Date()) -> Bool {
80
+        var didSave = false
81
+
82
+        context.performAndWait {
83
+            let expiredSessions = fetchOpenSessionObjects().compactMap { session -> NSManagedObject? in
84
+                guard automaticCompletionDate(for: session, referenceDate: referenceDate) != nil else {
85
+                    return nil
86
+                }
87
+                return session
88
+            }
89
+            guard expiredSessions.isEmpty == false else {
90
+                return
91
+            }
92
+
93
+            var chargedDeviceIDsToRefresh = Set<String>()
94
+            for session in expiredSessions {
95
+                guard let completionDate = automaticCompletionDate(for: session, referenceDate: referenceDate) else {
96
+                    continue
97
+                }
98
+                finishSession(
99
+                    session,
100
+                    observedAt: completionDate,
101
+                    finalBatteryPercent: nil,
102
+                    status: .completed
103
+                )
104
+                if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
105
+                    chargedDeviceIDsToRefresh.insert(chargedDeviceID)
106
+                }
107
+            }
108
+
109
+            guard saveContext() else {
110
+                return
111
+            }
112
+
113
+            for chargedDeviceID in chargedDeviceIDsToRefresh {
114
+                refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID)
115
+            }
116
+            didSave = saveContext()
117
+        }
118
+
119
+        return didSave
120
+    }
121
+
77 122
     @discardableResult
78 123
     func createDevice(
79 124
         name: String,
@@ -498,12 +543,11 @@ final class ChargeInsightsStore {
498 543
                 return
499 544
             }
500 545
 
501
-            let pausedAt = dateValue(session, key: "pausedAt") ?? Date()
502 546
             let resumedAt = snapshot?.observedAt ?? Date()
503
-            if resumedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout {
547
+            if let completionDate = automaticCompletionDate(for: session, referenceDate: resumedAt) {
504 548
                 finishSession(
505 549
                     session,
506
-                    observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
550
+                    observedAt: completionDate,
507 551
                     finalBatteryPercent: nil,
508 552
                     status: .completed
509 553
                 )
@@ -943,7 +987,7 @@ final class ChargeInsightsStore {
943 987
             }
944 988
 
945 989
             if statusValue(session, key: "statusRawValue") == .paused {
946
-                if maybeCompletePausedSession(session, observedAt: snapshot.observedAt) {
990
+                if maybeCompleteOpenSession(session, observedAt: snapshot.observedAt) {
947 991
                     didSave = true
948 992
                 }
949 993
                 return
@@ -965,19 +1009,29 @@ final class ChargeInsightsStore {
965 1009
                 fallback: optionalDoubleValue(session, key: "stopThresholdAmps")
966 1010
             )
967 1011
 
968
-            update(session: session, with: snapshot, stopThreshold: stopThreshold, charger: charger)
969
-            let aggregatedSample = updateAggregatedSample(session: session, with: snapshot)
1012
+            let sessionSnapshot = snapshotClampedToMaximumDuration(snapshot, for: session)
1013
+            update(session: session, with: sessionSnapshot, stopThreshold: stopThreshold, charger: charger)
1014
+            let aggregatedSample = updateAggregatedSample(session: session, with: sessionSnapshot)
1015
+            if let completionDate = automaticCompletionDate(for: session, referenceDate: snapshot.observedAt),
1016
+               statusValue(session, key: "statusRawValue")?.isOpen == true {
1017
+                finishSession(
1018
+                    session,
1019
+                    observedAt: completionDate,
1020
+                    finalBatteryPercent: nil,
1021
+                    status: .completed
1022
+                )
1023
+            }
970 1024
 
971
-            let saveReason = saveReason(for: session, observedAt: snapshot.observedAt)
1025
+            let saveReason = saveReason(for: session, observedAt: sessionSnapshot.observedAt)
972 1026
             let shouldPersistAggregatedCurve = aggregatedSample.map {
973
-                shouldPersistAggregatedSample($0, observedAt: snapshot.observedAt)
1027
+                shouldPersistAggregatedSample($0, observedAt: sessionSnapshot.observedAt)
974 1028
             } ?? false
975 1029
 
976 1030
             guard saveReason != .none || shouldPersistAggregatedCurve else {
977 1031
                 return
978 1032
             }
979 1033
 
980
-            session.setValue(snapshot.observedAt, forKey: "updatedAt")
1034
+            session.setValue(sessionSnapshot.observedAt, forKey: "updatedAt")
981 1035
 
982 1036
             if saveContext() {
983 1037
                 if saveReason == .completed, let deviceID = stringValue(session, key: "chargedDeviceID") {
@@ -1536,20 +1590,72 @@ final class ChargeInsightsStore {
1536 1590
         return dateValue(session, key: "lastObservedAt") ?? Date()
1537 1591
     }
1538 1592
 
1539
-    @discardableResult
1540
-    private func maybeCompletePausedSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1541
-        guard statusValue(session, key: "statusRawValue") == .paused else {
1542
-            return false
1593
+    private func snapshotClampedToMaximumDuration(
1594
+        _ snapshot: ChargingMonitorSnapshot,
1595
+        for session: NSManagedObject
1596
+    ) -> ChargingMonitorSnapshot {
1597
+        guard let maximumEndDate = maximumEndDate(for: session),
1598
+              snapshot.observedAt > maximumEndDate else {
1599
+            return snapshot
1600
+        }
1601
+
1602
+        return ChargingMonitorSnapshot(
1603
+            meterMACAddress: snapshot.meterMACAddress,
1604
+            meterName: snapshot.meterName,
1605
+            meterModel: snapshot.meterModel,
1606
+            observedAt: maximumEndDate,
1607
+            voltageVolts: snapshot.voltageVolts,
1608
+            currentAmps: snapshot.currentAmps,
1609
+            powerWatts: snapshot.powerWatts,
1610
+            selectedDataGroup: snapshot.selectedDataGroup,
1611
+            meterChargeCounterAh: snapshot.meterChargeCounterAh,
1612
+            meterEnergyCounterWh: snapshot.meterEnergyCounterWh,
1613
+            meterRecordingDurationSeconds: snapshot.meterRecordingDurationSeconds,
1614
+            fallbackStopThresholdAmps: snapshot.fallbackStopThresholdAmps
1615
+        )
1616
+    }
1617
+
1618
+    private func automaticCompletionDate(
1619
+        for session: NSManagedObject,
1620
+        referenceDate: Date
1621
+    ) -> Date? {
1622
+        guard statusValue(session, key: "statusRawValue")?.isOpen == true else {
1623
+            return nil
1624
+        }
1625
+
1626
+        var completionDates: [Date] = []
1627
+
1628
+        if let maximumEndDate = maximumEndDate(for: session) {
1629
+            completionDates.append(maximumEndDate)
1630
+        }
1631
+
1632
+        if statusValue(session, key: "statusRawValue") == .paused,
1633
+           let pausedAt = dateValue(session, key: "pausedAt") {
1634
+            completionDates.append(pausedAt.addingTimeInterval(pausedSessionTimeout))
1635
+        }
1636
+
1637
+        guard let completionDate = completionDates.min(),
1638
+              referenceDate >= completionDate else {
1639
+            return nil
1543 1640
         }
1544 1641
 
1545
-        guard let pausedAt = dateValue(session, key: "pausedAt"),
1546
-              observedAt.timeIntervalSince(pausedAt) >= pausedSessionTimeout else {
1642
+        return completionDate
1643
+    }
1644
+
1645
+    private func maximumEndDate(for session: NSManagedObject) -> Date? {
1646
+        dateValue(session, key: "startedAt")?.addingTimeInterval(Self.maximumChargeSessionDuration)
1647
+    }
1648
+
1649
+    @discardableResult
1650
+    private func maybeCompleteOpenSession(_ session: NSManagedObject, observedAt: Date) -> Bool {
1651
+        guard statusValue(session, key: "statusRawValue")?.isOpen == true,
1652
+              let completionDate = automaticCompletionDate(for: session, referenceDate: observedAt) else {
1547 1653
             return false
1548 1654
         }
1549 1655
 
1550 1656
         finishSession(
1551 1657
             session,
1552
-            observedAt: pausedAt.addingTimeInterval(pausedSessionTimeout),
1658
+            observedAt: completionDate,
1553 1659
             finalBatteryPercent: nil,
1554 1660
             status: .completed
1555 1661
         )
@@ -2438,6 +2544,17 @@ final class ChargeInsightsStore {
2438 2544
         )
2439 2545
     }
2440 2546
 
2547
+    private func fetchOpenSessionObjects() -> [NSManagedObject] {
2548
+        let request = NSFetchRequest<NSManagedObject>(entityName: EntityName.chargeSession)
2549
+        request.predicate = NSPredicate(
2550
+            format: "statusRawValue == %@ OR statusRawValue == %@",
2551
+            ChargeSessionStatus.active.rawValue,
2552
+            ChargeSessionStatus.paused.rawValue
2553
+        )
2554
+        request.sortDescriptors = [NSSortDescriptor(key: "startedAt", ascending: true)]
2555
+        return (try? context.fetch(request)) ?? []
2556
+    }
2557
+
2441 2558
     private func fetchActiveSessionObject(forMeterMACAddress meterMACAddress: String) -> NSManagedObject? {
2442 2559
         fetchSessionObject(
2443 2560
             predicate: NSPredicate(
+1 -2
USB Meter/Model/Meter.swift
@@ -986,8 +986,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
986 986
 
987 987
         if restoreSignature != restoredChargeRecordSignature {
988 988
             chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded(
989
-                from: activeSession,
990
-                replacingLiveBufferIfNeeded: activeSession.aggregatedSamples.isEmpty == false
989
+                from: activeSession
991 990
             )
992 991
             if activeSession.aggregatedSamples.isEmpty == false {
993 992
                 restoredChargeRecordSignature = restoreSignature