@@ -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 {
|
@@ -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> |
|
@@ -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 |
} |
@@ -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"), |
@@ -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() |
@@ -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 }
|
@@ -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 {
|
@@ -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, |