@@ -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 |
@@ -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( |
@@ -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 |