@@ -58,6 +58,8 @@ final class AppData : ObservableObject {
|
||
| 58 | 58 |
private var chargeInsightsStore: ChargeInsightsStore? |
| 59 | 59 |
private let chargerStandbyPowerStore = ChargerStandbyPowerStore() |
| 60 | 60 |
private let chargeNotificationCoordinator = ChargeNotificationCoordinator() |
| 61 |
+ private var meterSummariesCache: (version: Int, summaries: [MeterSummary])? |
|
| 62 |
+ private var meterSummariesVersion: Int = 0 |
|
| 61 | 63 |
|
| 62 | 64 |
init() {
|
| 63 | 65 |
bluetoothManagerNotification = bluetoothManager.objectWillChange.sink { [weak self] _ in
|
@@ -66,7 +68,9 @@ final class AppData : ObservableObject {
|
||
| 66 | 68 |
meterStoreObserver = NotificationCenter.default.publisher(for: .meterNameStoreDidChange) |
| 67 | 69 |
.receive(on: DispatchQueue.main) |
| 68 | 70 |
.sink { [weak self] _ in
|
| 71 |
+ self?.invalidateMeterSummaries() |
|
| 69 | 72 |
self?.refreshMeterMetadata() |
| 73 |
+ self?.scheduleObjectWillChange() |
|
| 70 | 74 |
} |
| 71 | 75 |
meterStoreCloudObserver = NotificationCenter.default.publisher(for: .meterNameStoreCloudStatusDidChange) |
| 72 | 76 |
.receive(on: DispatchQueue.main) |
@@ -84,7 +88,11 @@ final class AppData : ObservableObject {
|
||
| 84 | 88 |
|
| 85 | 89 |
@Published var enableRecordFeature: Bool = true |
| 86 | 90 |
|
| 87 |
- @Published var meters: [UUID:Meter] = [UUID:Meter]() |
|
| 91 |
+ @Published var meters: [UUID:Meter] = [UUID:Meter]() {
|
|
| 92 |
+ didSet {
|
|
| 93 |
+ invalidateMeterSummaries() |
|
| 94 |
+ } |
|
| 95 |
+ } |
|
| 88 | 96 |
@Published private(set) var chargedDevices: [ChargedDeviceSummary] = [] |
| 89 | 97 |
@Published private(set) var activeChargerStandbySessions: [String: ChargerStandbyPowerMonitorSession] = [:] |
| 90 | 98 |
|
@@ -268,10 +276,12 @@ final class AppData : ObservableObject {
|
||
| 268 | 276 |
} |
| 269 | 277 |
|
| 270 | 278 |
func activeChargeSessionSummary(for meterMACAddress: String) -> ChargeSessionSummary? {
|
| 271 |
- if let cachedSummary = cachedActiveChargeSessionSummary(for: meterMACAddress) {
|
|
| 279 |
+ let normalizedMAC = Self.normalizedMACAddress(meterMACAddress) |
|
| 280 |
+ |
|
| 281 |
+ if let cachedSummary = cachedActiveChargeSessionSummary(for: normalizedMAC) {
|
|
| 272 | 282 |
return cachedSummary |
| 273 | 283 |
} |
| 274 |
- return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: meterMACAddress) |
|
| 284 |
+ return chargeInsightsStore?.activeChargeSessionSummary(forMeterMACAddress: normalizedMAC) |
|
| 275 | 285 |
} |
| 276 | 286 |
|
| 277 | 287 |
func chargerStandbyMeasurementSession(for meterMACAddress: String) -> ChargerStandbyPowerMonitorSession? {
|
@@ -516,14 +526,19 @@ final class AppData : ObservableObject {
|
||
| 516 | 526 |
startsFromFlatBattery: startsFromFlatBattery |
| 517 | 527 |
) ?? false |
| 518 | 528 |
if didSave {
|
| 519 |
- reloadChargedDevices() |
|
| 520 | 529 |
meter.resetChargeRecordGraph() |
| 521 |
- if let activeSession = activeChargeSessionSummary(for: meter.btSerial.macAddress.description), |
|
| 530 |
+ let activeSession = chargeInsightsStore?.activeChargeSessionSummary( |
|
| 531 |
+ forMeterMACAddress: meter.btSerial.macAddress.description |
|
| 532 |
+ ) |
|
| 533 |
+ if let activeSession, |
|
| 522 | 534 |
meter.supportsRecordingThreshold, |
| 523 | 535 |
activeSession.stopThresholdAmps > 0 {
|
| 524 | 536 |
meter.recordingTreshold = activeSession.stopThresholdAmps |
| 525 | 537 |
} |
| 526 |
- restoreChargeMonitoringStateIfNeeded(for: meter) |
|
| 538 |
+ if let activeSession {
|
|
| 539 |
+ meter.restoreChargeMonitoringIfNeeded(from: activeSession) |
|
| 540 |
+ } |
|
| 541 |
+ reloadChargedDevices() |
|
| 527 | 542 |
} |
| 528 | 543 |
return didSave |
| 529 | 544 |
} |
@@ -698,6 +713,19 @@ final class AppData : ObservableObject {
|
||
| 698 | 713 |
return didDelete |
| 699 | 714 |
} |
| 700 | 715 |
|
| 716 |
+ @discardableResult |
|
| 717 |
+ func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
|
|
| 718 |
+ let didSave = chargeInsightsStore?.setSessionTrim( |
|
| 719 |
+ sessionID: sessionID, |
|
| 720 |
+ start: start, |
|
| 721 |
+ end: end |
|
| 722 |
+ ) ?? false |
|
| 723 |
+ if didSave {
|
|
| 724 |
+ reloadChargedDevices() |
|
| 725 |
+ } |
|
| 726 |
+ return didSave |
|
| 727 |
+ } |
|
| 728 |
+ |
|
| 701 | 729 |
@discardableResult |
| 702 | 730 |
func flushChargeInsights() -> Bool {
|
| 703 | 731 |
let didFlushObservations = flushAllPendingChargeObservations() |
@@ -837,11 +865,15 @@ final class AppData : ObservableObject {
|
||
| 837 | 865 |
} |
| 838 | 866 |
|
| 839 | 867 |
var meterSummaries: [MeterSummary] {
|
| 868 |
+ if let meterSummariesCache, meterSummariesCache.version == meterSummariesVersion {
|
|
| 869 |
+ return meterSummariesCache.summaries |
|
| 870 |
+ } |
|
| 871 |
+ |
|
| 840 | 872 |
let liveMetersByMAC = Dictionary(uniqueKeysWithValues: meters.values.map { ($0.btSerial.macAddress.description, $0) })
|
| 841 | 873 |
let recordsByMAC = Dictionary(uniqueKeysWithValues: meterStore.allRecords().map { ($0.macAddress, $0) })
|
| 842 | 874 |
let macAddresses = Set(recordsByMAC.keys).union(liveMetersByMAC.keys) |
| 843 | 875 |
|
| 844 |
- return macAddresses.map { macAddress in
|
|
| 876 |
+ let summaries = macAddresses.map { macAddress in
|
|
| 845 | 877 |
let liveMeter = liveMetersByMAC[macAddress] |
| 846 | 878 |
let record = recordsByMAC[macAddress] |
| 847 | 879 |
|
@@ -868,6 +900,9 @@ final class AppData : ObservableObject {
|
||
| 868 | 900 |
} |
| 869 | 901 |
return lhs.macAddress < rhs.macAddress |
| 870 | 902 |
} |
| 903 |
+ |
|
| 904 |
+ meterSummariesCache = (version: meterSummariesVersion, summaries: summaries) |
|
| 905 |
+ return summaries |
|
| 871 | 906 |
} |
| 872 | 907 |
|
| 873 | 908 |
private func scheduleObjectWillChange() {
|
@@ -876,6 +911,11 @@ final class AppData : ObservableObject {
|
||
| 876 | 911 |
} |
| 877 | 912 |
} |
| 878 | 913 |
|
| 914 |
+ private func invalidateMeterSummaries() {
|
|
| 915 |
+ meterSummariesVersion += 1 |
|
| 916 |
+ meterSummariesCache = nil |
|
| 917 |
+ } |
|
| 918 |
+ |
|
| 879 | 919 |
private func scheduleChargedDevicesReload(delay: TimeInterval = 0.15) {
|
| 880 | 920 |
pendingChargedDevicesReloadWorkItem?.cancel() |
| 881 | 921 |
|
@@ -1168,6 +1208,9 @@ final class AppData : ObservableObject {
|
||
| 1168 | 1208 |
|
| 1169 | 1209 |
private func displayedSessionEnergyWh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
| 1170 | 1210 |
let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
| 1211 |
+ guard session.isTrimmed == false else {
|
|
| 1212 |
+ return storedEnergyWh |
|
| 1213 |
+ } |
|
| 1171 | 1214 |
guard session.status.isOpen else {
|
| 1172 | 1215 |
return storedEnergyWh |
| 1173 | 1216 |
} |
@@ -1185,6 +1228,9 @@ final class AppData : ObservableObject {
|
||
| 1185 | 1228 |
|
| 1186 | 1229 |
private func displayedSessionChargeAh(for session: ChargeSessionSummary, on meter: Meter) -> Double {
|
| 1187 | 1230 |
let storedChargeAh = session.measuredChargeAh |
| 1231 |
+ guard session.isTrimmed == false else {
|
|
| 1232 |
+ return storedChargeAh |
|
| 1233 |
+ } |
|
| 1188 | 1234 |
guard session.status.isOpen else {
|
| 1189 | 1235 |
return storedChargeAh |
| 1190 | 1236 |
} |
@@ -30,7 +30,7 @@ final class ChargeInsightsStore {
|
||
| 30 | 30 |
} |
| 31 | 31 |
} |
| 32 | 32 |
|
| 33 |
- private static let persistedSamplesPerHour = 300 |
|
| 33 |
+ private static let persistedSamplesPerHour = 360 |
|
| 34 | 34 |
private static let aggregatedSampleBucketDuration = 3600.0 / Double(persistedSamplesPerHour) |
| 35 | 35 |
|
| 36 | 36 |
private let context: NSManagedObjectContext |
@@ -741,6 +741,104 @@ final class ChargeInsightsStore {
|
||
| 741 | 741 |
return didSave |
| 742 | 742 |
} |
| 743 | 743 |
|
| 744 |
+ @discardableResult |
|
| 745 |
+ func setSessionTrim(sessionID: UUID, start: Date?, end: Date?) -> Bool {
|
|
| 746 |
+ var didSave = false |
|
| 747 |
+ context.performAndWait {
|
|
| 748 |
+ guard let session = fetchSessionObject(id: sessionID.uuidString) else {
|
|
| 749 |
+ return |
|
| 750 |
+ } |
|
| 751 |
+ |
|
| 752 |
+ let sessionStart = dateValue(session, key: "startedAt") ?? Date.distantPast |
|
| 753 |
+ let sessionEnd = dateValue(session, key: "endedAt") |
|
| 754 |
+ ?? dateValue(session, key: "lastObservedAt") |
|
| 755 |
+ ?? Date.distantFuture |
|
| 756 |
+ |
|
| 757 |
+ let effectiveStart = min(max(start ?? sessionStart, sessionStart), sessionEnd) |
|
| 758 |
+ let effectiveEnd = max(min(end ?? sessionEnd, sessionEnd), effectiveStart) |
|
| 759 |
+ let persistedStart = effectiveStart == sessionStart ? nil : effectiveStart |
|
| 760 |
+ let persistedEnd = effectiveEnd == sessionEnd ? nil : effectiveEnd |
|
| 761 |
+ |
|
| 762 |
+ let allSamples = fetchSessionSampleObjects(forSessionID: sessionID.uuidString) |
|
| 763 |
+ .compactMap { obj -> (timestamp: Date, energy: Double, charge: Double)? in
|
|
| 764 |
+ guard let ts = dateValue(obj, key: "timestamp") else { return nil }
|
|
| 765 |
+ return ( |
|
| 766 |
+ timestamp: ts, |
|
| 767 |
+ energy: doubleValue(obj, key: "measuredEnergyWh"), |
|
| 768 |
+ charge: doubleValue(obj, key: "measuredChargeAh") |
|
| 769 |
+ ) |
|
| 770 |
+ } |
|
| 771 |
+ .sorted { $0.timestamp < $1.timestamp }
|
|
| 772 |
+ |
|
| 773 |
+ // Each sample stores cumulative energy since session start. |
|
| 774 |
+ // Trimmed energy = value at trimEnd - value just before trimStart. |
|
| 775 |
+ let baselineSample = allSamples.last { $0.timestamp <= effectiveStart }
|
|
| 776 |
+ let endSample = allSamples.last { $0.timestamp <= effectiveEnd }
|
|
| 777 |
+ let baselineEnergy = baselineSample?.energy ?? 0 |
|
| 778 |
+ let baselineCharge = baselineSample?.charge ?? 0 |
|
| 779 |
+ |
|
| 780 |
+ if let endSample {
|
|
| 781 |
+ let trimmedEnergy = max(endSample.energy - baselineEnergy, 0) |
|
| 782 |
+ let trimmedCharge = max(endSample.charge - baselineCharge, 0) |
|
| 783 |
+ session.setValue(trimmedEnergy, forKey: "measuredEnergyWh") |
|
| 784 |
+ session.setValue(trimmedCharge, forKey: "measuredChargeAh") |
|
| 785 |
+ } else {
|
|
| 786 |
+ session.setValue(0, forKey: "measuredEnergyWh") |
|
| 787 |
+ session.setValue(0, forKey: "measuredChargeAh") |
|
| 788 |
+ } |
|
| 789 |
+ |
|
| 790 |
+ session.setValue(persistedStart, forKey: "trimStart") |
|
| 791 |
+ session.setValue(persistedEnd, forKey: "trimEnd") |
|
| 792 |
+ session.setValue(Date(), forKey: "updatedAt") |
|
| 793 |
+ |
|
| 794 |
+ let checkpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString) |
|
| 795 |
+ for checkpoint in checkpoints {
|
|
| 796 |
+ guard let timestamp = dateValue(checkpoint, key: "timestamp") else { continue }
|
|
| 797 |
+ |
|
| 798 |
+ if timestamp < effectiveStart || timestamp > effectiveEnd {
|
|
| 799 |
+ context.delete(checkpoint) |
|
| 800 |
+ continue |
|
| 801 |
+ } |
|
| 802 |
+ |
|
| 803 |
+ let checkpointSample = allSamples.last { $0.timestamp <= timestamp }
|
|
| 804 |
+ let rebasedEnergy = max((checkpointSample?.energy ?? baselineEnergy) - baselineEnergy, 0) |
|
| 805 |
+ let rebasedCharge = max((checkpointSample?.charge ?? baselineCharge) - baselineCharge, 0) |
|
| 806 |
+ checkpoint.setValue(rebasedEnergy, forKey: "measuredEnergyWh") |
|
| 807 |
+ checkpoint.setValue(rebasedCharge, forKey: "measuredChargeAh") |
|
| 808 |
+ } |
|
| 809 |
+ |
|
| 810 |
+ let remainingCheckpoints = fetchCheckpointObjects(forSessionID: sessionID.uuidString) |
|
| 811 |
+ .sorted {
|
|
| 812 |
+ (dateValue($0, key: "timestamp") ?? .distantPast) |
|
| 813 |
+ < (dateValue($1, key: "timestamp") ?? .distantPast) |
|
| 814 |
+ } |
|
| 815 |
+ let restoredInitialCheckpoint = remainingCheckpoints.first { checkpoint in
|
|
| 816 |
+ let label = stringValue(checkpoint, key: "label") |
|
| 817 |
+ let timestamp = dateValue(checkpoint, key: "timestamp") ?? .distantFuture |
|
| 818 |
+ return ChargeCheckpointFlag.fromStoredLabel(label) == .initial || timestamp <= effectiveStart |
|
| 819 |
+ } |
|
| 820 |
+ |
|
| 821 |
+ if persistedStart == nil {
|
|
| 822 |
+ if let restoredInitialCheckpoint, |
|
| 823 |
+ let percent = optionalDoubleValue(restoredInitialCheckpoint, key: "batteryPercent"), |
|
| 824 |
+ percent >= 0 {
|
|
| 825 |
+ session.setValue(percent, forKey: "startBatteryPercent") |
|
| 826 |
+ } |
|
| 827 |
+ } else {
|
|
| 828 |
+ session.setValue(nil, forKey: "startBatteryPercent") |
|
| 829 |
+ } |
|
| 830 |
+ |
|
| 831 |
+ refreshCheckpointDerivedValues(for: session) |
|
| 832 |
+ |
|
| 833 |
+ if let chargedDeviceID = stringValue(session, key: "chargedDeviceID") {
|
|
| 834 |
+ refreshDerivedMetrics(forChargedDeviceID: chargedDeviceID) |
|
| 835 |
+ } |
|
| 836 |
+ |
|
| 837 |
+ didSave = saveContext() |
|
| 838 |
+ } |
|
| 839 |
+ return didSave |
|
| 840 |
+ } |
|
| 841 |
+ |
|
| 744 | 842 |
@discardableResult |
| 745 | 843 |
func deleteChargeSession(id sessionID: UUID) -> Bool {
|
| 746 | 844 |
var didSave = false |
@@ -1566,7 +1664,9 @@ final class ChargeInsightsStore {
|
||
| 1566 | 1664 |
Anchor( |
| 1567 | 1665 |
percent: startBatteryPercent, |
| 1568 | 1666 |
energyWh: 0, |
| 1569 |
- timestamp: dateValue(session, key: "startedAt") ?? Date.distantPast, |
|
| 1667 |
+ timestamp: dateValue(session, key: "trimStart") |
|
| 1668 |
+ ?? dateValue(session, key: "startedAt") |
|
| 1669 |
+ ?? Date.distantPast, |
|
| 1570 | 1670 |
isCheckpoint: false |
| 1571 | 1671 |
) |
| 1572 | 1672 |
) |
@@ -1650,33 +1750,94 @@ final class ChargeInsightsStore {
|
||
| 1650 | 1750 |
session.setValue(wirelessResolution?.usesEstimated ?? false, forKey: "usesEstimatedWirelessEfficiency") |
| 1651 | 1751 |
session.setValue(wirelessResolution?.shouldWarn ?? false, forKey: "shouldWarnAboutLowWirelessEfficiency") |
| 1652 | 1752 |
|
| 1653 |
- guard |
|
| 1654 |
- let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 1655 |
- let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent") |
|
| 1656 |
- else {
|
|
| 1753 |
+ let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff") |
|
| 1754 |
+ |
|
| 1755 |
+ guard let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
|
|
| 1657 | 1756 |
session.setValue(nil, forKey: "capacityEstimateWh") |
| 1658 | 1757 |
return |
| 1659 | 1758 |
} |
| 1660 | 1759 |
|
| 1661 |
- guard startBatteryPercent >= 0, endBatteryPercent >= 0 else {
|
|
| 1760 |
+ struct CapacityAnchor {
|
|
| 1761 |
+ let percent: Double |
|
| 1762 |
+ let energyWh: Double |
|
| 1763 |
+ let timestamp: Date |
|
| 1764 |
+ } |
|
| 1765 |
+ |
|
| 1766 |
+ var anchors: [CapacityAnchor] = [] |
|
| 1767 |
+ |
|
| 1768 |
+ if let startBatteryPercent = optionalDoubleValue(session, key: "startBatteryPercent"), |
|
| 1769 |
+ startBatteryPercent >= 0 {
|
|
| 1770 |
+ anchors.append( |
|
| 1771 |
+ CapacityAnchor( |
|
| 1772 |
+ percent: startBatteryPercent, |
|
| 1773 |
+ energyWh: 0, |
|
| 1774 |
+ timestamp: dateValue(session, key: "trimStart") |
|
| 1775 |
+ ?? dateValue(session, key: "startedAt") |
|
| 1776 |
+ ?? Date.distantPast |
|
| 1777 |
+ ) |
|
| 1778 |
+ ) |
|
| 1779 |
+ } |
|
| 1780 |
+ |
|
| 1781 |
+ if let sessionID = stringValue(session, key: "id") {
|
|
| 1782 |
+ anchors.append( |
|
| 1783 |
+ contentsOf: fetchCheckpointObjects(forSessionID: sessionID).compactMap { checkpoint in
|
|
| 1784 |
+ guard |
|
| 1785 |
+ let percent = optionalDoubleValue(checkpoint, key: "batteryPercent"), |
|
| 1786 |
+ percent >= 0, |
|
| 1787 |
+ let timestamp = dateValue(checkpoint, key: "timestamp") |
|
| 1788 |
+ else {
|
|
| 1789 |
+ return nil |
|
| 1790 |
+ } |
|
| 1791 |
+ |
|
| 1792 |
+ return CapacityAnchor( |
|
| 1793 |
+ percent: percent, |
|
| 1794 |
+ energyWh: doubleValue(checkpoint, key: "measuredEnergyWh"), |
|
| 1795 |
+ timestamp: timestamp |
|
| 1796 |
+ ) |
|
| 1797 |
+ } |
|
| 1798 |
+ ) |
|
| 1799 |
+ } |
|
| 1800 |
+ |
|
| 1801 |
+ if let endBatteryPercent = optionalDoubleValue(session, key: "endBatteryPercent"), |
|
| 1802 |
+ endBatteryPercent >= 0 {
|
|
| 1803 |
+ anchors.append( |
|
| 1804 |
+ CapacityAnchor( |
|
| 1805 |
+ percent: endBatteryPercent, |
|
| 1806 |
+ energyWh: effectiveBatteryEnergyWh, |
|
| 1807 |
+ timestamp: dateValue(session, key: "endedAt") |
|
| 1808 |
+ ?? dateValue(session, key: "lastObservedAt") |
|
| 1809 |
+ ?? Date.distantPast |
|
| 1810 |
+ ) |
|
| 1811 |
+ ) |
|
| 1812 |
+ } |
|
| 1813 |
+ |
|
| 1814 |
+ let sortedAnchors = anchors.sorted { lhs, rhs in
|
|
| 1815 |
+ if lhs.energyWh != rhs.energyWh {
|
|
| 1816 |
+ return lhs.energyWh < rhs.energyWh |
|
| 1817 |
+ } |
|
| 1818 |
+ return lhs.timestamp < rhs.timestamp |
|
| 1819 |
+ } |
|
| 1820 |
+ |
|
| 1821 |
+ guard let firstAnchor = sortedAnchors.first, |
|
| 1822 |
+ let lastAnchor = sortedAnchors.last else {
|
|
| 1662 | 1823 |
session.setValue(nil, forKey: "capacityEstimateWh") |
| 1663 | 1824 |
return |
| 1664 | 1825 |
} |
| 1665 | 1826 |
|
| 1666 |
- let percentDelta = endBatteryPercent - startBatteryPercent |
|
| 1667 |
- let supportsChargingWhileOff = boolValue(chargedDevice, key: "supportsChargingWhileOff") |
|
| 1827 |
+ let percentDelta = lastAnchor.percent - firstAnchor.percent |
|
| 1828 |
+ let energyDelta = lastAnchor.energyWh - firstAnchor.energyWh |
|
| 1668 | 1829 |
|
| 1669 |
- guard percentDelta >= 20, let effectiveBatteryEnergyWh, effectiveBatteryEnergyWh > 0 else {
|
|
| 1830 |
+ guard percentDelta >= 20, energyDelta > 0 else {
|
|
| 1670 | 1831 |
session.setValue(nil, forKey: "capacityEstimateWh") |
| 1671 | 1832 |
return |
| 1672 | 1833 |
} |
| 1673 | 1834 |
|
| 1674 |
- if !supportsChargingWhileOff && endBatteryPercent >= 99.5 {
|
|
| 1835 |
+ if !supportsChargingWhileOff && lastAnchor.percent >= 99.5 {
|
|
| 1675 | 1836 |
session.setValue(nil, forKey: "capacityEstimateWh") |
| 1676 | 1837 |
return |
| 1677 | 1838 |
} |
| 1678 | 1839 |
|
| 1679 |
- let capacityEstimateWh = effectiveBatteryEnergyWh / (percentDelta / 100) |
|
| 1840 |
+ let capacityEstimateWh = energyDelta / (percentDelta / 100) |
|
| 1680 | 1841 |
session.setValue(capacityEstimateWh, forKey: "capacityEstimateWh") |
| 1681 | 1842 |
} |
| 1682 | 1843 |
|
@@ -2205,6 +2366,8 @@ final class ChargeInsightsStore {
|
||
| 2205 | 2366 |
completionConfirmationRequestedAt: dateValue(object, key: "completionConfirmationRequestedAt"), |
| 2206 | 2367 |
completionContradictionPercent: optionalDoubleValue(object, key: "completionContradictionPercent"), |
| 2207 | 2368 |
selectedDataGroup: optionalInt16Value(object, key: "selectedDataGroup").map(UInt8.init), |
| 2369 |
+ trimStart: dateValue(object, key: "trimStart"), |
|
| 2370 |
+ trimEnd: dateValue(object, key: "trimEnd"), |
|
| 2208 | 2371 |
checkpoints: checkpointSummaries, |
| 2209 | 2372 |
aggregatedSamples: sampleSummaries |
| 2210 | 2373 |
) |
@@ -71,6 +71,12 @@ enum ChargeRecordState {
|
||
| 71 | 71 |
} |
| 72 | 72 |
|
| 73 | 73 |
class Meter : NSObject, ObservableObject, Identifiable {
|
| 74 |
+ private struct ChargeRecordRestoreSignature: Equatable {
|
|
| 75 |
+ let sessionID: UUID |
|
| 76 |
+ let sampleCount: Int |
|
| 77 |
+ let lastSampleTimestamp: Date? |
|
| 78 |
+ } |
|
| 79 |
+ |
|
| 74 | 80 |
|
| 75 | 81 |
private static func shouldLogOperationalStateTransition(from oldValue: OperationalState, to newValue: OperationalState) -> Bool {
|
| 76 | 82 |
switch (oldValue, newValue) {
|
@@ -428,7 +434,8 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 428 | 434 |
|
| 429 | 435 |
var btSerial: BluetoothSerial |
| 430 | 436 |
|
| 431 |
- var measurements = Measurements() |
|
| 437 |
+ let measurements = Measurements() |
|
| 438 |
+ let chargeRecordMeasurements = Measurements() |
|
| 432 | 439 |
|
| 433 | 440 |
private let minimumLivePollingInterval: TimeInterval = 0.4 |
| 434 | 441 |
private var commandQueue: [Data] = [] |
@@ -530,6 +537,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 530 | 537 |
private var pendingVolatileMemoryResetDeadline: Date? |
| 531 | 538 |
private var liveDataChanged = false |
| 532 | 539 |
private var restoredChargeSessionID: UUID? |
| 540 |
+ private var restoredChargeRecordSignature: ChargeRecordRestoreSignature? |
|
| 533 | 541 |
private var lastRecorderObservationAt: Date? |
| 534 | 542 |
|
| 535 | 543 |
@discardableResult |
@@ -590,6 +598,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 590 | 598 |
|
| 591 | 599 |
private func handleMeasurementDiscontinuity(at timestamp: Date) {
|
| 592 | 600 |
measurements.markDiscontinuity(at: timestamp) |
| 601 |
+ chargeRecordMeasurements.markDiscontinuity(at: timestamp) |
|
| 593 | 602 |
|
| 594 | 603 |
guard chargeRecordState == .active else { return }
|
| 595 | 604 |
chargeRecordLastTimestamp = nil |
@@ -807,27 +816,10 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 807 | 816 |
} |
| 808 | 817 |
} |
| 809 | 818 |
updateChargeRecord(at: dataDumpRequestTimestamp) |
| 810 |
- if supportsRecordingView {
|
|
| 811 |
- measurements.captureEnergyValue( |
|
| 812 |
- timestamp: dataDumpRequestTimestamp, |
|
| 813 |
- value: recordedWH, |
|
| 814 |
- groupID: .max |
|
| 815 |
- ) |
|
| 816 |
- } else if let energySample = currentEnergySample() {
|
|
| 817 |
- measurements.captureEnergyValue( |
|
| 818 |
- timestamp: dataDumpRequestTimestamp, |
|
| 819 |
- value: energySample.value, |
|
| 820 |
- groupID: energySample.groupID |
|
| 821 |
- ) |
|
| 819 |
+ captureLiveMeasurements(at: dataDumpRequestTimestamp, in: measurements) |
|
| 820 |
+ if chargeRecordState != .waitingForStart {
|
|
| 821 |
+ captureLiveMeasurements(at: dataDumpRequestTimestamp, in: chargeRecordMeasurements) |
|
| 822 | 822 |
} |
| 823 |
- measurements.addValues( |
|
| 824 |
- timestamp: dataDumpRequestTimestamp, |
|
| 825 |
- power: power, |
|
| 826 |
- voltage: voltage, |
|
| 827 |
- current: current, |
|
| 828 |
- temperature: displayedTemperatureValue, |
|
| 829 |
- rssi: Double(btSerial.averageRSSI) |
|
| 830 |
- ) |
|
| 831 | 823 |
appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp) |
| 832 | 824 |
// DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
|
| 833 | 825 |
// //track("\(name) - Scheduled new request.")
|
@@ -974,35 +966,120 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 974 | 966 |
chargeRecordLastTimestamp = nil |
| 975 | 967 |
chargeRecordLastCurrent = 0 |
| 976 | 968 |
chargeRecordLastPower = 0 |
| 969 |
+ restoredChargeSessionID = nil |
|
| 970 |
+ restoredChargeRecordSignature = nil |
|
| 971 |
+ chargeRecordMeasurements.resetSeries() |
|
| 977 | 972 |
objectWillChange.send() |
| 978 | 973 |
} |
| 979 | 974 |
|
| 980 | 975 |
func resetChargeRecordGraph() {
|
| 981 |
- let cutoff = Date() |
|
| 982 | 976 |
resetChargeRecord() |
| 983 |
- measurements.trim(before: cutoff) |
|
| 984 | 977 |
} |
| 985 | 978 |
|
| 986 | 979 |
func restoreChargeRecordIfNeeded(from activeSession: ChargeSessionSummary) {
|
| 987 |
- guard chargeRecordState == .waitingForStart else { return }
|
|
| 988 |
- guard chargeRecordStartTimestamp == nil else { return }
|
|
| 989 |
- guard chargeRecordAH == 0, chargeRecordWH == 0, chargeRecordDuration == 0 else { return }
|
|
| 990 |
- |
|
| 991 |
- measurements.restorePersistedChargeSessionSamplesIfNeeded(from: activeSession) |
|
| 992 |
- chargeRecordState = .active |
|
| 993 |
- chargeRecordAH = activeSession.measuredChargeAh |
|
| 994 |
- chargeRecordWH = activeSession.measuredEnergyWh |
|
| 995 |
- chargeRecordDuration = max(activeSession.effectiveDuration, 0) |
|
| 996 |
- chargeRecordStopThreshold = activeSession.stopThresholdAmps |
|
| 997 |
- chargeRecordStartTimestamp = activeSession.startedAt |
|
| 998 |
- chargeRecordEndTimestamp = activeSession.lastObservedAt |
|
| 999 |
- chargeRecordLastTimestamp = nil |
|
| 1000 |
- chargeRecordLastCurrent = 0 |
|
| 1001 |
- chargeRecordLastPower = 0 |
|
| 980 |
+ var didChange = false |
|
| 981 |
+ let restoreSignature = ChargeRecordRestoreSignature( |
|
| 982 |
+ sessionID: activeSession.id, |
|
| 983 |
+ sampleCount: activeSession.aggregatedSamples.count, |
|
| 984 |
+ lastSampleTimestamp: activeSession.aggregatedSamples.last?.timestamp |
|
| 985 |
+ ) |
|
| 986 |
+ |
|
| 987 |
+ if restoreSignature != restoredChargeRecordSignature {
|
|
| 988 |
+ chargeRecordMeasurements.restorePersistedChargeSessionSamplesIfNeeded( |
|
| 989 |
+ from: activeSession, |
|
| 990 |
+ replacingLiveBufferIfNeeded: activeSession.aggregatedSamples.isEmpty == false |
|
| 991 |
+ ) |
|
| 992 |
+ if activeSession.aggregatedSamples.isEmpty == false {
|
|
| 993 |
+ restoredChargeRecordSignature = restoreSignature |
|
| 994 |
+ didChange = true |
|
| 995 |
+ } |
|
| 996 |
+ } |
|
| 997 |
+ |
|
| 998 |
+ if chargeRecordState != .active {
|
|
| 999 |
+ chargeRecordState = .active |
|
| 1000 |
+ didChange = true |
|
| 1001 |
+ } |
|
| 1002 |
+ |
|
| 1003 |
+ let resolvedChargeAH = max(chargeRecordAH, activeSession.measuredChargeAh) |
|
| 1004 |
+ if resolvedChargeAH != chargeRecordAH {
|
|
| 1005 |
+ chargeRecordAH = resolvedChargeAH |
|
| 1006 |
+ didChange = true |
|
| 1007 |
+ } |
|
| 1008 |
+ |
|
| 1009 |
+ let resolvedChargeWH = max(chargeRecordWH, activeSession.measuredEnergyWh) |
|
| 1010 |
+ if resolvedChargeWH != chargeRecordWH {
|
|
| 1011 |
+ chargeRecordWH = resolvedChargeWH |
|
| 1012 |
+ didChange = true |
|
| 1013 |
+ } |
|
| 1014 |
+ |
|
| 1015 |
+ let resolvedDuration = max(chargeRecordDuration, max(activeSession.effectiveDuration, 0)) |
|
| 1016 |
+ if resolvedDuration != chargeRecordDuration {
|
|
| 1017 |
+ chargeRecordDuration = resolvedDuration |
|
| 1018 |
+ didChange = true |
|
| 1019 |
+ } |
|
| 1020 |
+ |
|
| 1021 |
+ if chargeRecordStopThreshold != activeSession.stopThresholdAmps {
|
|
| 1022 |
+ chargeRecordStopThreshold = activeSession.stopThresholdAmps |
|
| 1023 |
+ didChange = true |
|
| 1024 |
+ } |
|
| 1025 |
+ |
|
| 1026 |
+ if let chargeRecordStartTimestamp {
|
|
| 1027 |
+ let restoredStart = min(chargeRecordStartTimestamp, activeSession.startedAt) |
|
| 1028 |
+ if restoredStart != chargeRecordStartTimestamp {
|
|
| 1029 |
+ self.chargeRecordStartTimestamp = restoredStart |
|
| 1030 |
+ didChange = true |
|
| 1031 |
+ } |
|
| 1032 |
+ } else {
|
|
| 1033 |
+ chargeRecordStartTimestamp = activeSession.startedAt |
|
| 1034 |
+ didChange = true |
|
| 1035 |
+ } |
|
| 1036 |
+ |
|
| 1037 |
+ if let chargeRecordEndTimestamp {
|
|
| 1038 |
+ let restoredEnd = max(chargeRecordEndTimestamp, activeSession.lastObservedAt) |
|
| 1039 |
+ if restoredEnd != chargeRecordEndTimestamp {
|
|
| 1040 |
+ self.chargeRecordEndTimestamp = restoredEnd |
|
| 1041 |
+ didChange = true |
|
| 1042 |
+ } |
|
| 1043 |
+ } else {
|
|
| 1044 |
+ chargeRecordEndTimestamp = activeSession.lastObservedAt |
|
| 1045 |
+ didChange = true |
|
| 1046 |
+ } |
|
| 1047 |
+ |
|
| 1002 | 1048 |
if let selectedDataGroup = activeSession.selectedDataGroup {
|
| 1003 |
- self.selectedDataGroup = selectedDataGroup |
|
| 1049 |
+ if self.selectedDataGroup != selectedDataGroup {
|
|
| 1050 |
+ self.selectedDataGroup = selectedDataGroup |
|
| 1051 |
+ didChange = true |
|
| 1052 |
+ } |
|
| 1004 | 1053 |
} |
| 1005 |
- objectWillChange.send() |
|
| 1054 |
+ |
|
| 1055 |
+ if didChange {
|
|
| 1056 |
+ objectWillChange.send() |
|
| 1057 |
+ } |
|
| 1058 |
+ } |
|
| 1059 |
+ |
|
| 1060 |
+ private func captureLiveMeasurements(at timestamp: Date, in destination: Measurements) {
|
|
| 1061 |
+ if supportsRecordingView {
|
|
| 1062 |
+ destination.captureEnergyValue( |
|
| 1063 |
+ timestamp: timestamp, |
|
| 1064 |
+ value: recordedWH, |
|
| 1065 |
+ groupID: .max |
|
| 1066 |
+ ) |
|
| 1067 |
+ } else if let energySample = currentEnergySample() {
|
|
| 1068 |
+ destination.captureEnergyValue( |
|
| 1069 |
+ timestamp: timestamp, |
|
| 1070 |
+ value: energySample.value, |
|
| 1071 |
+ groupID: energySample.groupID |
|
| 1072 |
+ ) |
|
| 1073 |
+ } |
|
| 1074 |
+ |
|
| 1075 |
+ destination.addValues( |
|
| 1076 |
+ timestamp: timestamp, |
|
| 1077 |
+ power: power, |
|
| 1078 |
+ voltage: voltage, |
|
| 1079 |
+ current: current, |
|
| 1080 |
+ temperature: displayedTemperatureValue, |
|
| 1081 |
+ rssi: Double(btSerial.averageRSSI) |
|
| 1082 |
+ ) |
|
| 1006 | 1083 |
} |
| 1007 | 1084 |
|
| 1008 | 1085 |
func restoreChargeMonitoringIfNeeded(from activeSession: ChargeSessionSummary) {
|
@@ -10,29 +10,10 @@ import SwiftUI |
||
| 10 | 10 |
|
| 11 | 11 |
struct ChargeRecordSheetView: View {
|
| 12 | 12 |
@Binding var visibility: Bool |
| 13 |
- @EnvironmentObject private var appData: AppData |
|
| 14 |
- |
|
| 15 |
- @State private var chargedDeviceLibraryVisibility = false |
|
| 16 |
- @State private var chargerLibraryVisibility = false |
|
| 17 |
- @State private var deviceLibraryMACAddress = "" |
|
| 18 |
- @State private var chargerLibraryMACAddress = "" |
|
| 19 |
- @State private var chargedDeviceLibraryTint: Color = .orange |
|
| 20 |
- @State private var chargerLibraryTint: Color = .pink |
|
| 21 | 13 |
|
| 22 | 14 |
var body: some View {
|
| 23 | 15 |
NavigationView {
|
| 24 |
- MeterChargeRecordContentView( |
|
| 25 |
- onSelectDevice: { mac, tint in
|
|
| 26 |
- deviceLibraryMACAddress = mac |
|
| 27 |
- chargedDeviceLibraryTint = tint |
|
| 28 |
- chargedDeviceLibraryVisibility = true |
|
| 29 |
- }, |
|
| 30 |
- onSelectCharger: { mac, tint in
|
|
| 31 |
- chargerLibraryMACAddress = mac |
|
| 32 |
- chargerLibraryTint = tint |
|
| 33 |
- chargerLibraryVisibility = true |
|
| 34 |
- } |
|
| 35 |
- ) |
|
| 16 |
+ MeterChargeRecordContentView() |
|
| 36 | 17 |
.navigationTitle("Charge Record")
|
| 37 | 18 |
.navigationBarTitleDisplayMode(.inline) |
| 38 | 19 |
.toolbar {
|
@@ -42,29 +23,6 @@ struct ChargeRecordSheetView: View {
|
||
| 42 | 23 |
} |
| 43 | 24 |
} |
| 44 | 25 |
} |
| 45 |
- .background( |
|
| 46 |
- Group {
|
|
| 47 |
- NavigationLink(isActive: $chargedDeviceLibraryVisibility) {
|
|
| 48 |
- ChargedDeviceLibrarySheetView( |
|
| 49 |
- meterMACAddress: deviceLibraryMACAddress, |
|
| 50 |
- meterTint: chargedDeviceLibraryTint, |
|
| 51 |
- mode: .device, |
|
| 52 |
- standalone: false |
|
| 53 |
- ) |
|
| 54 |
- .environmentObject(appData) |
|
| 55 |
- } label: { EmptyView() }
|
|
| 56 |
- |
|
| 57 |
- NavigationLink(isActive: $chargerLibraryVisibility) {
|
|
| 58 |
- ChargedDeviceLibrarySheetView( |
|
| 59 |
- meterMACAddress: chargerLibraryMACAddress, |
|
| 60 |
- meterTint: chargerLibraryTint, |
|
| 61 |
- mode: .charger, |
|
| 62 |
- standalone: false |
|
| 63 |
- ) |
|
| 64 |
- .environmentObject(appData) |
|
| 65 |
- } label: { EmptyView() }
|
|
| 66 |
- } |
|
| 67 |
- ) |
|
| 68 | 26 |
} |
| 69 | 27 |
.navigationViewStyle(StackNavigationViewStyle()) |
| 70 | 28 |
} |
@@ -10,44 +10,8 @@ struct MeterChargeRecordTabView: View, Equatable {
|
||
| 10 | 10 |
true |
| 11 | 11 |
} |
| 12 | 12 |
|
| 13 |
- @EnvironmentObject private var appData: AppData |
|
| 14 |
- |
|
| 15 |
- @State private var chargedDeviceLibraryVisibility = false |
|
| 16 |
- @State private var chargerLibraryVisibility = false |
|
| 17 |
- @State private var deviceLibraryMACAddress = "" |
|
| 18 |
- @State private var chargerLibraryMACAddress = "" |
|
| 19 |
- @State private var chargedDeviceLibraryTint: Color = .orange |
|
| 20 |
- @State private var chargerLibraryTint: Color = .pink |
|
| 21 |
- |
|
| 22 | 13 |
var body: some View {
|
| 23 |
- MeterChargeRecordContentView( |
|
| 24 |
- onSelectDevice: { mac, tint in
|
|
| 25 |
- deviceLibraryMACAddress = mac |
|
| 26 |
- chargedDeviceLibraryTint = tint |
|
| 27 |
- chargedDeviceLibraryVisibility = true |
|
| 28 |
- }, |
|
| 29 |
- onSelectCharger: { mac, tint in
|
|
| 30 |
- chargerLibraryMACAddress = mac |
|
| 31 |
- chargerLibraryTint = tint |
|
| 32 |
- chargerLibraryVisibility = true |
|
| 33 |
- } |
|
| 34 |
- ) |
|
| 35 |
- .sheet(isPresented: $chargedDeviceLibraryVisibility) {
|
|
| 36 |
- ChargedDeviceLibrarySheetView( |
|
| 37 |
- meterMACAddress: deviceLibraryMACAddress, |
|
| 38 |
- meterTint: chargedDeviceLibraryTint, |
|
| 39 |
- mode: .device |
|
| 40 |
- ) |
|
| 41 |
- .environmentObject(appData) |
|
| 42 |
- } |
|
| 43 |
- .sheet(isPresented: $chargerLibraryVisibility) {
|
|
| 44 |
- ChargedDeviceLibrarySheetView( |
|
| 45 |
- meterMACAddress: chargerLibraryMACAddress, |
|
| 46 |
- meterTint: chargerLibraryTint, |
|
| 47 |
- mode: .charger |
|
| 48 |
- ) |
|
| 49 |
- .environmentObject(appData) |
|
| 50 |
- } |
|
| 14 |
+ MeterChargeRecordContentView() |
|
| 51 | 15 |
} |
| 52 | 16 |
} |
| 53 | 17 |
|
@@ -134,9 +98,6 @@ struct MeterChargeRecordContentView: View {
|
||
| 134 | 98 |
} |
| 135 | 99 |
} |
| 136 | 100 |
|
| 137 |
- let onSelectDevice: (String, Color) -> Void |
|
| 138 |
- let onSelectCharger: (String, Color) -> Void |
|
| 139 |
- |
|
| 140 | 101 |
@EnvironmentObject private var appData: AppData |
| 141 | 102 |
@EnvironmentObject private var usbMeter: Meter |
| 142 | 103 |
|
@@ -152,6 +113,16 @@ struct MeterChargeRecordContentView: View {
|
||
| 152 | 113 |
@State private var initialCheckpoint = "" |
| 153 | 114 |
@State private var showsMeterTotalsInfo = false |
| 154 | 115 |
@State private var activeMode: ActiveMode = .chargeSession |
| 116 |
+ @State private var detectedTrimWindow: ChargingWindowDetector.DetectedWindow? |
|
| 117 |
+ @State private var trimBannerDismissedForSessionID: UUID? |
|
| 118 |
+ |
|
| 119 |
+ private var shouldShowTrimBanner: Bool {
|
|
| 120 |
+ guard let session = openChargeSession, |
|
| 121 |
+ session.isTrimmed == false, |
|
| 122 |
+ trimBannerDismissedForSessionID != session.id else { return false }
|
|
| 123 |
+ guard let window = detectedTrimWindow else { return false }
|
|
| 124 |
+ return window.trimRatio > ChargingWindowDetector.significantTrimThreshold |
|
| 125 |
+ } |
|
| 155 | 126 |
|
| 156 | 127 |
var body: some View {
|
| 157 | 128 |
ScrollView {
|
@@ -161,8 +132,15 @@ struct MeterChargeRecordContentView: View {
|
||
| 161 | 132 |
if let openChargeSession {
|
| 162 | 133 |
chargingMonitorCard(openChargeSession) |
| 163 | 134 |
|
| 164 |
- if let range = sessionChartTimeRange {
|
|
| 165 |
- sessionChartCard(timeRange: range, session: openChargeSession) |
|
| 135 |
+ if shouldShowTrimBanner {
|
|
| 136 |
+ trimDetectionBanner(for: openChargeSession) |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ if shouldShowSessionChart(for: openChargeSession) {
|
|
| 140 |
+ sessionChartCard( |
|
| 141 |
+ timeRange: sessionChartFixedTimeRange(for: openChargeSession), |
|
| 142 |
+ session: openChargeSession |
|
| 143 |
+ ) |
|
| 166 | 144 |
} |
| 167 | 145 |
} else {
|
| 168 | 146 |
liveMeterStripView |
@@ -202,16 +180,48 @@ struct MeterChargeRecordContentView: View {
|
||
| 202 | 180 |
) |
| 203 | 181 |
} |
| 204 | 182 |
.onAppear {
|
| 183 |
+ syncActiveSessionRestore() |
|
| 205 | 184 |
syncDraftSelections() |
| 185 |
+ runTrimDetection() |
|
| 206 | 186 |
} |
| 207 | 187 |
.onChange(of: selectedChargedDevice?.id) { _ in
|
| 208 | 188 |
syncDraftSelections() |
| 209 | 189 |
} |
| 210 | 190 |
.onChange(of: openChargeSession?.id) { _ in
|
| 191 |
+ syncActiveSessionRestore() |
|
| 211 | 192 |
syncDraftSelections() |
| 212 | 193 |
showingInlineTargetEditor = false |
| 213 | 194 |
draftTargetText = "" |
| 195 |
+ detectedTrimWindow = nil |
|
| 196 |
+ trimBannerDismissedForSessionID = nil |
|
| 197 |
+ runTrimDetection() |
|
| 198 |
+ } |
|
| 199 |
+ .onChange(of: openChargeSession?.aggregatedSamples.count) { _ in
|
|
| 200 |
+ syncActiveSessionRestore() |
|
| 201 |
+ runTrimDetection() |
|
| 202 |
+ } |
|
| 203 |
+ } |
|
| 204 |
+ |
|
| 205 |
+ private func syncActiveSessionRestore() {
|
|
| 206 |
+ guard let session = openChargeSession else { return }
|
|
| 207 |
+ guard session.status == .active else { return }
|
|
| 208 |
+ guard session.meterMACAddress == meterMACAddress else { return }
|
|
| 209 |
+ usbMeter.restoreChargeRecordIfNeeded(from: session) |
|
| 210 |
+ } |
|
| 211 |
+ |
|
| 212 |
+ private func runTrimDetection() {
|
|
| 213 |
+ guard let session = openChargeSession, |
|
| 214 |
+ session.isTrimmed == false, |
|
| 215 |
+ !session.aggregatedSamples.isEmpty else {
|
|
| 216 |
+ detectedTrimWindow = nil |
|
| 217 |
+ return |
|
| 214 | 218 |
} |
| 219 |
+ let sessionEnd = session.endedAt ?? session.lastObservedAt |
|
| 220 |
+ detectedTrimWindow = ChargingWindowDetector.detect( |
|
| 221 |
+ samples: session.aggregatedSamples, |
|
| 222 |
+ sessionStart: session.startedAt, |
|
| 223 |
+ sessionEnd: sessionEnd |
|
| 224 |
+ ) |
|
| 215 | 225 |
} |
| 216 | 226 |
|
| 217 | 227 |
// MARK: - Computed Properties |
@@ -224,10 +234,38 @@ struct MeterChargeRecordContentView: View {
|
||
| 224 | 234 |
appData.currentChargedDeviceSummary(for: meterMACAddress) |
| 225 | 235 |
} |
| 226 | 236 |
|
| 237 |
+ private var availableChargedDevices: [ChargedDeviceSummary] {
|
|
| 238 |
+ appData.deviceSummaries |
|
| 239 |
+ } |
|
| 240 |
+ |
|
| 241 |
+ private var selectedChargedDeviceID: Binding<UUID?> {
|
|
| 242 |
+ Binding( |
|
| 243 |
+ get: { selectedChargedDevice?.id },
|
|
| 244 |
+ set: { newValue in
|
|
| 245 |
+ guard let newValue else { return }
|
|
| 246 |
+ _ = appData.assignChargedDevice(newValue, to: meterMACAddress) |
|
| 247 |
+ } |
|
| 248 |
+ ) |
|
| 249 |
+ } |
|
| 250 |
+ |
|
| 227 | 251 |
private var selectedCharger: ChargedDeviceSummary? {
|
| 228 | 252 |
appData.currentChargerSummary(for: meterMACAddress) |
| 229 | 253 |
} |
| 230 | 254 |
|
| 255 |
+ private var availableChargers: [ChargedDeviceSummary] {
|
|
| 256 |
+ appData.chargerSummaries |
|
| 257 |
+ } |
|
| 258 |
+ |
|
| 259 |
+ private var selectedChargerID: Binding<UUID?> {
|
|
| 260 |
+ Binding( |
|
| 261 |
+ get: { selectedCharger?.id },
|
|
| 262 |
+ set: { newValue in
|
|
| 263 |
+ guard let newValue else { return }
|
|
| 264 |
+ _ = appData.assignCharger(newValue, to: meterMACAddress) |
|
| 265 |
+ } |
|
| 266 |
+ ) |
|
| 267 |
+ } |
|
| 268 |
+ |
|
| 231 | 269 |
private var openChargeSession: ChargeSessionSummary? {
|
| 232 | 270 |
appData.activeChargeSessionSummary(for: meterMACAddress) |
| 233 | 271 |
} |
@@ -337,10 +375,26 @@ struct MeterChargeRecordContentView: View {
|
||
| 337 | 375 |
} |
| 338 | 376 |
} |
| 339 | 377 |
|
| 340 |
- private var sessionChartTimeRange: ClosedRange<Date>? {
|
|
| 341 |
- guard let openChargeSession else { return nil }
|
|
| 342 |
- let end = openChargeSession.pausedAt ?? openChargeSession.lastObservedAt |
|
| 343 |
- return openChargeSession.startedAt...max(end, openChargeSession.startedAt) |
|
| 378 |
+ private func shouldShowSessionChart(for session: ChargeSessionSummary) -> Bool {
|
|
| 379 |
+ sessionChartFixedTimeRange(for: session) != nil || usesChargeRecordBuffer(for: session) |
|
| 380 |
+ } |
|
| 381 |
+ |
|
| 382 |
+ private func sessionChartFixedTimeRange(for session: ChargeSessionSummary) -> ClosedRange<Date>? {
|
|
| 383 |
+ if usesChargeRecordBuffer(for: session) {
|
|
| 384 |
+ return nil |
|
| 385 |
+ } |
|
| 386 |
+ return session.effectiveTimeRange |
|
| 387 |
+ } |
|
| 388 |
+ |
|
| 389 |
+ private func sessionChartLiveTrimBounds(for session: ChargeSessionSummary) -> (lower: Date?, upper: Date?) {
|
|
| 390 |
+ guard usesChargeRecordBuffer(for: session) else {
|
|
| 391 |
+ return (nil, nil) |
|
| 392 |
+ } |
|
| 393 |
+ return (session.trimStart, session.trimEnd) |
|
| 394 |
+ } |
|
| 395 |
+ |
|
| 396 |
+ private func usesChargeRecordBuffer(for session: ChargeSessionSummary) -> Bool {
|
|
| 397 |
+ session.status.isOpen && session.meterMACAddress == meterMACAddress |
|
| 344 | 398 |
} |
| 345 | 399 |
|
| 346 | 400 |
private var showsWirelessChargerSection: Bool {
|
@@ -386,21 +440,29 @@ struct MeterChargeRecordContentView: View {
|
||
| 386 | 440 |
VStack(alignment: .leading, spacing: 0) {
|
| 387 | 441 |
// Device |
| 388 | 442 |
setupRow(icon: "iphone", iconColor: .blue) {
|
| 389 |
- if let device = selectedChargedDevice {
|
|
| 390 |
- ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) |
|
| 391 |
- .font(.subheadline.weight(.semibold)) |
|
| 392 |
- } else {
|
|
| 393 |
- Text("No device selected")
|
|
| 394 |
- .foregroundColor(.secondary) |
|
| 395 |
- .font(.subheadline) |
|
| 396 |
- } |
|
| 397 |
- Spacer(minLength: 8) |
|
| 398 |
- Button(selectedChargedDevice == nil ? "Select" : "Change") {
|
|
| 399 |
- onSelectDevice(meterMACAddress, usbMeter.color) |
|
| 443 |
+ Picker(selection: selectedChargedDeviceID) {
|
|
| 444 |
+ Text("Choose device").tag(UUID?.none)
|
|
| 445 |
+ ForEach(availableChargedDevices) { device in
|
|
| 446 |
+ Text(device.name).tag(Optional(device.id)) |
|
| 447 |
+ } |
|
| 448 |
+ } label: {
|
|
| 449 |
+ HStack(spacing: 8) {
|
|
| 450 |
+ if let device = selectedChargedDevice {
|
|
| 451 |
+ ChargedDeviceIdentityLabelView(chargedDevice: device, iconPointSize: 15) |
|
| 452 |
+ .font(.subheadline.weight(.semibold)) |
|
| 453 |
+ } else {
|
|
| 454 |
+ Text(availableChargedDevices.isEmpty ? "No devices available" : "Choose device") |
|
| 455 |
+ .foregroundColor(.secondary) |
|
| 456 |
+ .font(.subheadline) |
|
| 457 |
+ } |
|
| 458 |
+ Spacer(minLength: 8) |
|
| 459 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 460 |
+ .font(.caption.weight(.semibold)) |
|
| 461 |
+ .foregroundColor(.secondary) |
|
| 462 |
+ } |
|
| 400 | 463 |
} |
| 401 |
- .font(.caption.weight(.semibold)) |
|
| 402 |
- .buttonStyle(.bordered) |
|
| 403 |
- .controlSize(.small) |
|
| 464 |
+ .pickerStyle(.menu) |
|
| 465 |
+ .disabled(availableChargedDevices.isEmpty) |
|
| 404 | 466 |
} |
| 405 | 467 |
|
| 406 | 468 |
// Charging type — only when device supports multiple |
@@ -449,26 +511,34 @@ struct MeterChargeRecordContentView: View {
|
||
| 449 | 511 |
if showsWirelessChargerSection {
|
| 450 | 512 |
Divider().padding(.leading, 46) |
| 451 | 513 |
setupRow(icon: "antenna.radiowaves.left.and.right", iconColor: .teal) {
|
| 452 |
- if let charger = selectedCharger {
|
|
| 453 |
- ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15) |
|
| 454 |
- .font(.subheadline.weight(.semibold)) |
|
| 455 |
- if charger.chargerIdleCurrentAmps == nil {
|
|
| 456 |
- Image(systemName: "exclamationmark.triangle.fill") |
|
| 457 |
- .foregroundColor(.orange) |
|
| 458 |
- .font(.caption) |
|
| 514 |
+ Picker(selection: selectedChargerID) {
|
|
| 515 |
+ Text("Choose charger").tag(UUID?.none)
|
|
| 516 |
+ ForEach(availableChargers) { charger in
|
|
| 517 |
+ Text(charger.name).tag(Optional(charger.id)) |
|
| 518 |
+ } |
|
| 519 |
+ } label: {
|
|
| 520 |
+ HStack(spacing: 8) {
|
|
| 521 |
+ if let charger = selectedCharger {
|
|
| 522 |
+ ChargedDeviceIdentityLabelView(chargedDevice: charger, iconPointSize: 15) |
|
| 523 |
+ .font(.subheadline.weight(.semibold)) |
|
| 524 |
+ if charger.chargerIdleCurrentAmps == nil {
|
|
| 525 |
+ Image(systemName: "exclamationmark.triangle.fill") |
|
| 526 |
+ .foregroundColor(.orange) |
|
| 527 |
+ .font(.caption) |
|
| 528 |
+ } |
|
| 529 |
+ } else {
|
|
| 530 |
+ Text(availableChargers.isEmpty ? "No chargers available" : "Choose charger") |
|
| 531 |
+ .foregroundColor(.secondary) |
|
| 532 |
+ .font(.subheadline) |
|
| 533 |
+ } |
|
| 534 |
+ Spacer(minLength: 8) |
|
| 535 |
+ Image(systemName: "chevron.up.chevron.down") |
|
| 536 |
+ .font(.caption.weight(.semibold)) |
|
| 537 |
+ .foregroundColor(.secondary) |
|
| 459 | 538 |
} |
| 460 |
- } else {
|
|
| 461 |
- Text("No charger selected")
|
|
| 462 |
- .foregroundColor(.secondary) |
|
| 463 |
- .font(.subheadline) |
|
| 464 |
- } |
|
| 465 |
- Spacer(minLength: 8) |
|
| 466 |
- Button(selectedCharger == nil ? "Select" : "Change") {
|
|
| 467 |
- onSelectCharger(meterMACAddress, usbMeter.color) |
|
| 468 | 539 |
} |
| 469 |
- .font(.caption.weight(.semibold)) |
|
| 470 |
- .buttonStyle(.bordered) |
|
| 471 |
- .controlSize(.small) |
|
| 540 |
+ .pickerStyle(.menu) |
|
| 541 |
+ .disabled(availableChargers.isEmpty) |
|
| 472 | 542 |
} |
| 473 | 543 |
} |
| 474 | 544 |
|
@@ -758,7 +828,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 758 | 828 |
) -> some View {
|
| 759 | 829 |
let percent = prediction.predictedPercent |
| 760 | 830 |
let color = batteryColor(for: percent) |
| 761 |
- let duration = max(session.effectiveDuration, 0) |
|
| 831 |
+ let duration = displayedSessionDuration(for: session) |
|
| 762 | 832 |
let rateWhPerSec: Double? = duration > 300 && displayedEnergyWh > 0.01 |
| 763 | 833 |
? displayedEnergyWh / duration |
| 764 | 834 |
: nil |
@@ -908,6 +978,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 908 | 978 |
displayedEnergyWh: Double, |
| 909 | 979 |
hasPrediction: Bool |
| 910 | 980 |
) -> some View {
|
| 981 |
+ let displayedDuration = displayedSessionDuration(for: session) |
|
| 911 | 982 |
let capacityFallback: Double? = hasPrediction ? nil : ( |
| 912 | 983 |
session.capacityEstimateWh |
| 913 | 984 |
?? selectedChargedDevice?.estimatedBatteryCapacityWh(for: session.chargingTransportMode) |
@@ -917,7 +988,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 917 | 988 |
|
| 918 | 989 |
return LazyVGrid(columns: columns, spacing: 8) {
|
| 919 | 990 |
metricCell(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh", tint: .blue) |
| 920 |
- metricCell(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0)), tint: .teal) |
|
| 991 |
+ metricCell(label: "Duration", value: formatDuration(displayedDuration), tint: .teal) |
|
| 921 | 992 |
|
| 922 | 993 |
if shouldShowChargingTransport(for: session) {
|
| 923 | 994 |
metricCell(label: "Type", value: session.chargingTransportMode.title, tint: .orange) |
@@ -1253,7 +1324,62 @@ struct MeterChargeRecordContentView: View {
|
||
| 1253 | 1324 |
finalCheckpointText = next.format(decimalDigits: 0) |
| 1254 | 1325 |
} |
| 1255 | 1326 |
|
| 1256 |
- private func sessionChartCard(timeRange: ClosedRange<Date>, session: ChargeSessionSummary) -> some View {
|
|
| 1327 |
+ // MARK: - Trim Detection Banner |
|
| 1328 |
+ |
|
| 1329 |
+ @ViewBuilder |
|
| 1330 |
+ private func trimDetectionBanner(for session: ChargeSessionSummary) -> some View {
|
|
| 1331 |
+ if let window = detectedTrimWindow {
|
|
| 1332 |
+ HStack(spacing: 12) {
|
|
| 1333 |
+ Image(systemName: "scissors.circle.fill") |
|
| 1334 |
+ .font(.title3) |
|
| 1335 |
+ .foregroundColor(.blue) |
|
| 1336 |
+ |
|
| 1337 |
+ VStack(alignment: .leading, spacing: 2) {
|
|
| 1338 |
+ Text("Charging ended early")
|
|
| 1339 |
+ .font(.subheadline.weight(.semibold)) |
|
| 1340 |
+ Text("Active charging detected between \(window.start.format(as: "HH:mm")) and \(window.end.format(as: "HH:mm")). The rest may be standby or another device.")
|
|
| 1341 |
+ .font(.caption) |
|
| 1342 |
+ .foregroundColor(.secondary) |
|
| 1343 |
+ .fixedSize(horizontal: false, vertical: true) |
|
| 1344 |
+ } |
|
| 1345 |
+ |
|
| 1346 |
+ Spacer(minLength: 0) |
|
| 1347 |
+ |
|
| 1348 |
+ VStack(spacing: 6) {
|
|
| 1349 |
+ Button("Apply") {
|
|
| 1350 |
+ _ = appData.setSessionTrim( |
|
| 1351 |
+ sessionID: session.id, |
|
| 1352 |
+ start: window.start, |
|
| 1353 |
+ end: window.end |
|
| 1354 |
+ ) |
|
| 1355 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1356 |
+ } |
|
| 1357 |
+ .font(.caption.weight(.semibold)) |
|
| 1358 |
+ .buttonStyle(.borderedProminent) |
|
| 1359 |
+ .controlSize(.small) |
|
| 1360 |
+ .tint(.blue) |
|
| 1361 |
+ |
|
| 1362 |
+ Button {
|
|
| 1363 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1364 |
+ } label: {
|
|
| 1365 |
+ Image(systemName: "xmark") |
|
| 1366 |
+ .font(.caption2.weight(.semibold)) |
|
| 1367 |
+ .foregroundColor(.secondary) |
|
| 1368 |
+ } |
|
| 1369 |
+ .buttonStyle(.plain) |
|
| 1370 |
+ } |
|
| 1371 |
+ } |
|
| 1372 |
+ .padding(14) |
|
| 1373 |
+ .background( |
|
| 1374 |
+ RoundedRectangle(cornerRadius: 14) |
|
| 1375 |
+ .fill(Color.blue.opacity(0.10)) |
|
| 1376 |
+ .overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.blue.opacity(0.22), lineWidth: 1)) |
|
| 1377 |
+ ) |
|
| 1378 |
+ .transition(.opacity.combined(with: .move(edge: .top))) |
|
| 1379 |
+ } |
|
| 1380 |
+ } |
|
| 1381 |
+ |
|
| 1382 |
+ private func sessionChartCard(timeRange: ClosedRange<Date>?, session: ChargeSessionSummary) -> some View {
|
|
| 1257 | 1383 |
VStack(alignment: .leading, spacing: 12) {
|
| 1258 | 1384 |
HStack(spacing: 8) {
|
| 1259 | 1385 |
Image(systemName: "chart.xyaxis.line") |
@@ -1262,8 +1388,11 @@ struct MeterChargeRecordContentView: View {
|
||
| 1262 | 1388 |
.font(.headline) |
| 1263 | 1389 |
ContextInfoButton( |
| 1264 | 1390 |
title: "Session Chart", |
| 1265 |
- message: "The chart is scoped to the explicit session window for \(session.sessionKind.shortTitle.lowercased()) charging." |
|
| 1391 |
+ message: usesChargeRecordBuffer(for: session) |
|
| 1392 |
+ ? "This chart combines the persisted session curve with current live data from this meter." |
|
| 1393 |
+ : "This chart is scoped to the saved session window for \(session.sessionKind.shortTitle.lowercased()) charging." |
|
| 1266 | 1394 |
) |
| 1395 |
+ Spacer(minLength: 0) |
|
| 1267 | 1396 |
} |
| 1268 | 1397 |
|
| 1269 | 1398 |
GeometryReader { geometry in
|
@@ -1275,10 +1404,41 @@ struct MeterChargeRecordContentView: View {
|
||
| 1275 | 1404 |
compactLayout: compactChartLayout, |
| 1276 | 1405 |
availableSize: CGSize(width: chartWidth, height: chartHeight), |
| 1277 | 1406 |
timeRange: timeRange, |
| 1278 |
- showsRangeSelector: false, |
|
| 1279 |
- rebasesEnergyToVisibleRangeStart: true |
|
| 1407 |
+ timeRangeLowerBound: sessionChartLiveTrimBounds(for: session).lower, |
|
| 1408 |
+ timeRangeUpperBound: sessionChartLiveTrimBounds(for: session).upper, |
|
| 1409 |
+ showsRangeSelector: session.aggregatedSamples.isEmpty == false, |
|
| 1410 |
+ rebasesEnergyToVisibleRangeStart: true, |
|
| 1411 |
+ extendsTimelineToPresent: false, |
|
| 1412 |
+ rangeSelectorConfiguration: session.aggregatedSamples.isEmpty |
|
| 1413 |
+ ? nil |
|
| 1414 |
+ : MeasurementChartRangeSelectorConfiguration( |
|
| 1415 |
+ keepAction: MeasurementChartSelectionAction( |
|
| 1416 |
+ title: compactChartLayout ? "Keep" : "Keep Selection", |
|
| 1417 |
+ systemName: "scissors", |
|
| 1418 |
+ tone: .destructive, |
|
| 1419 |
+ handler: { range in
|
|
| 1420 |
+ _ = appData.setSessionTrim( |
|
| 1421 |
+ sessionID: session.id, |
|
| 1422 |
+ start: range.lowerBound, |
|
| 1423 |
+ end: range.upperBound |
|
| 1424 |
+ ) |
|
| 1425 |
+ trimBannerDismissedForSessionID = session.id |
|
| 1426 |
+ } |
|
| 1427 |
+ ), |
|
| 1428 |
+ removeAction: nil, |
|
| 1429 |
+ resetAction: MeasurementChartResetAction( |
|
| 1430 |
+ title: compactChartLayout ? "Reset" : "Reset Trim", |
|
| 1431 |
+ systemName: "arrow.counterclockwise", |
|
| 1432 |
+ tone: .reversible, |
|
| 1433 |
+ confirmationTitle: "Reset session trim?", |
|
| 1434 |
+ confirmationButtonTitle: "Reset trim", |
|
| 1435 |
+ handler: {
|
|
| 1436 |
+ _ = appData.setSessionTrim(sessionID: session.id, start: nil, end: nil) |
|
| 1437 |
+ } |
|
| 1438 |
+ ) |
|
| 1439 |
+ ) |
|
| 1280 | 1440 |
) |
| 1281 |
- .environmentObject(usbMeter.measurements) |
|
| 1441 |
+ .environmentObject(usbMeter.chargeRecordMeasurements) |
|
| 1282 | 1442 |
.frame(maxWidth: .infinity, alignment: .topLeading) |
| 1283 | 1443 |
} |
| 1284 | 1444 |
.frame(height: 350) |
@@ -1382,7 +1542,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 1382 | 1542 |
} |
| 1383 | 1543 |
|
| 1384 | 1544 |
rows.append(SessionMetricRow(label: "Energy", value: "\(displayedEnergyWh.format(decimalDigits: 3)) Wh")) |
| 1385 |
- rows.append(SessionMetricRow(label: "Duration", value: formatDuration(max(session.effectiveDuration, 0)))) |
|
| 1545 |
+ rows.append(SessionMetricRow(label: "Duration", value: formatDuration(displayedSessionDuration(for: session)))) |
|
| 1386 | 1546 |
rows.append(SessionMetricRow(label: "Auto Stop", value: autoStopLabel(for: session))) |
| 1387 | 1547 |
return rows |
| 1388 | 1548 |
} |
@@ -1401,6 +1561,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 1401 | 1561 |
|
| 1402 | 1562 |
private func displayedSessionEnergyWh(for session: ChargeSessionSummary) -> Double {
|
| 1403 | 1563 |
let storedEnergyWh = session.effectiveOrMeasuredEnergyWh |
| 1564 |
+ guard session.isTrimmed == false else { return storedEnergyWh }
|
|
| 1404 | 1565 |
guard session.status.isOpen else { return storedEnergyWh }
|
| 1405 | 1566 |
guard session.meterMACAddress == meterMACAddress else { return storedEnergyWh }
|
| 1406 | 1567 |
if let baselineEnergyWh = session.meterEnergyBaselineWh {
|
@@ -1411,6 +1572,7 @@ struct MeterChargeRecordContentView: View {
|
||
| 1411 | 1572 |
|
| 1412 | 1573 |
private func displayedSessionChargeAh(for session: ChargeSessionSummary) -> Double {
|
| 1413 | 1574 |
let storedChargeAh = session.measuredChargeAh |
| 1575 |
+ guard session.isTrimmed == false else { return storedChargeAh }
|
|
| 1414 | 1576 |
guard session.status.isOpen else { return storedChargeAh }
|
| 1415 | 1577 |
guard session.meterMACAddress == meterMACAddress else { return storedChargeAh }
|
| 1416 | 1578 |
if let baselineChargeAh = session.meterChargeBaselineAh {
|
@@ -1419,6 +1581,14 @@ struct MeterChargeRecordContentView: View {
|
||
| 1419 | 1581 |
return storedChargeAh |
| 1420 | 1582 |
} |
| 1421 | 1583 |
|
| 1584 |
+ private func displayedSessionDuration(for session: ChargeSessionSummary) -> TimeInterval {
|
|
| 1585 |
+ let storedDuration = max(session.effectiveDuration, 0) |
|
| 1586 |
+ guard session.isTrimmed == false else { return storedDuration }
|
|
| 1587 |
+ guard session.status.isOpen else { return storedDuration }
|
|
| 1588 |
+ guard session.meterMACAddress == meterMACAddress else { return storedDuration }
|
|
| 1589 |
+ return max(storedDuration, max(usbMeter.chargeRecordDuration, 0)) |
|
| 1590 |
+ } |
|
| 1591 |
+ |
|
| 1422 | 1592 |
private func formatDuration(_ duration: TimeInterval) -> String {
|
| 1423 | 1593 |
let totalSeconds = Int(duration.rounded(.down)) |
| 1424 | 1594 |
let hours = totalSeconds / 3600 |
@@ -1547,4 +1717,3 @@ struct MeterChargeRecordContentView: View {
|
||
| 1547 | 1717 |
.buttonStyle(.plain) |
| 1548 | 1718 |
} |
| 1549 | 1719 |
} |
| 1550 |
- |
|