Showing 6 changed files with 632 additions and 26 deletions
+91 -0
USB Meter/AppDelegate.swift
@@ -18,6 +18,9 @@ enum Constants {
18 18
 
19 19
 // MARK: Debug
20 20
 public func track(_ message: String = "", file: String = #file, function: String = #function, line: Int = #line ) {
21
+    guard shouldEmitTrackMessage(message, file: file, function: function) else {
22
+        return
23
+    }
21 24
     let date = Date()
22 25
     let calendar = Calendar.current
23 26
     let hour = calendar.component(.hour, from: date)
@@ -26,6 +29,94 @@ public func track(_ message: String = "", file: String = #file, function: String
26 29
     print("\(hour):\(minutes):\(seconds) - \(file):\(line) - \(function) \(message)")
27 30
 }
28 31
 
32
+private func shouldEmitTrackMessage(_ message: String, file: String, function: String) -> Bool {
33
+    #if DEBUG
34
+    if ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" {
35
+        return true
36
+    }
37
+
38
+    #if targetEnvironment(macCatalyst)
39
+    let importantMarkers = [
40
+        "Error",
41
+        "error",
42
+        "Failed",
43
+        "failed",
44
+        "timeout",
45
+        "Timeout",
46
+        "Missing",
47
+        "missing",
48
+        "overflow",
49
+        "Disconnect",
50
+        "disconnect",
51
+        "Disconnected",
52
+        "unauthorized",
53
+        "not authorized",
54
+        "not supported",
55
+        "Unexpected",
56
+        "Invalid Context",
57
+        "ignored",
58
+        "Guard:",
59
+        "Skip data request",
60
+        "Dropping unsolicited data",
61
+        "This is not possible!",
62
+        "Inferred",
63
+        "Clearing",
64
+        "Reconnecting"
65
+    ]
66
+
67
+    if importantMarkers.contains(where: { message.contains($0) }) {
68
+        return true
69
+    }
70
+
71
+    let noisyFunctions: Set<String> = [
72
+        "logRuntimeICloudDiagnostics()",
73
+        "refreshCloudAvailability(reason:)",
74
+        "start()",
75
+        "centralManagerDidUpdateState(_:)",
76
+        "discoveredMeter(peripheral:advertising:rssi:)",
77
+        "connect()",
78
+        "connectionEstablished()",
79
+        "peripheral(_:didDiscoverServices:)",
80
+        "peripheral(_:didDiscoverCharacteristicsFor:error:)",
81
+        "refreshOperationalStateIfReady()",
82
+        "peripheral(_:didUpdateNotificationStateFor:error:)",
83
+        "scheduleDataDumpRequest(after:reason:)"
84
+    ]
85
+
86
+    if noisyFunctions.contains(function) {
87
+        return false
88
+    }
89
+
90
+    let noisyMarkers = [
91
+        "Runtime iCloud diagnostics",
92
+        "iCloud availability",
93
+        "Starting Bluetooth manager",
94
+        "Bluetooth is On... Start scanning...",
95
+        "adding new USB Meter",
96
+        "Connect called for",
97
+        "Connection established for",
98
+        "Optional([<CBService:",
99
+        "Optional([<CBCharacteristic:",
100
+        "Waiting for notifications on",
101
+        "Notification state updated for",
102
+        "Peripheral ready with notify",
103
+        "Schedule data request in",
104
+        "Operational state changed"
105
+    ]
106
+
107
+    if noisyMarkers.contains(where: { message.contains($0) }) {
108
+        return false
109
+    }
110
+    #endif
111
+
112
+    return true
113
+    #else
114
+    _ = file
115
+    _ = function
116
+    return false
117
+    #endif
118
+}
119
+
29 120
 @UIApplicationMain
30 121
 class AppDelegate: UIResponder, UIApplicationDelegate {
31 122
 
+54 -1
USB Meter/Model/Measurements.swift
@@ -99,6 +99,10 @@ class Measurements : ObservableObject {
99 99
     @Published var power = Measurement()
100 100
     @Published var voltage = Measurement()
101 101
     @Published var current = Measurement()
102
+    @Published var temperature = Measurement()
103
+    @Published var rssi = Measurement()
104
+
105
+    let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
102 106
 
103 107
     private var pendingBucketSecond: Int?
104 108
     private var pendingBucketTimestamp: Date?
@@ -107,6 +111,8 @@ class Measurements : ObservableObject {
107 111
     private var powerSum: Double = 0
108 112
     private var voltageSum: Double = 0
109 113
     private var currentSum: Double = 0
114
+    private var temperatureSum: Double = 0
115
+    private var rssiSum: Double = 0
110 116
 
111 117
     private func resetPendingAggregation() {
112 118
         pendingBucketSecond = nil
@@ -115,6 +121,8 @@ class Measurements : ObservableObject {
115 121
         powerSum = 0
116 122
         voltageSum = 0
117 123
         currentSum = 0
124
+        temperatureSum = 0
125
+        rssiSum = 0
118 126
     }
119 127
 
120 128
     private func flushPendingValues() {
@@ -122,6 +130,8 @@ class Measurements : ObservableObject {
122 130
         self.power.addPoint(timestamp: pendingBucketTimestamp, value: powerSum / itemsInSum)
123 131
         self.voltage.addPoint(timestamp: pendingBucketTimestamp, value: voltageSum / itemsInSum)
124 132
         self.current.addPoint(timestamp: pendingBucketTimestamp, value: currentSum / itemsInSum)
133
+        self.temperature.addPoint(timestamp: pendingBucketTimestamp, value: temperatureSum / itemsInSum)
134
+        self.rssi.addPoint(timestamp: pendingBucketTimestamp, value: rssiSum / itemsInSum)
125 135
         resetPendingAggregation()
126 136
         self.objectWillChange.send()
127 137
     }
@@ -130,6 +140,8 @@ class Measurements : ObservableObject {
130 140
         power.resetSeries()
131 141
         voltage.resetSeries()
132 142
         current.resetSeries()
143
+        temperature.resetSeries()
144
+        rssi.resetSeries()
133 145
         resetPendingAggregation()
134 146
         self.objectWillChange.send()
135 147
     }
@@ -142,6 +154,8 @@ class Measurements : ObservableObject {
142 154
         power.removeValue(index: idx)
143 155
         voltage.removeValue(index: idx)
144 156
         current.removeValue(index: idx)
157
+        temperature.removeValue(index: idx)
158
+        rssi.removeValue(index: idx)
145 159
         self.objectWillChange.send()
146 160
     }
147 161
 
@@ -150,10 +164,12 @@ class Measurements : ObservableObject {
150 164
         power.trim(before: cutoff)
151 165
         voltage.trim(before: cutoff)
152 166
         current.trim(before: cutoff)
167
+        temperature.trim(before: cutoff)
168
+        rssi.trim(before: cutoff)
153 169
         self.objectWillChange.send()
154 170
     }
155 171
 
156
-    func addValues(timestamp: Date, power: Double, voltage: Double, current: Double) {
172
+    func addValues(timestamp: Date, power: Double, voltage: Double, current: Double, temperature: Double, rssi: Double) {
157 173
         let valuesTimestamp = timestamp.timeIntervalSinceReferenceDate.intValue
158 174
 
159 175
         if pendingBucketSecond == valuesTimestamp {
@@ -162,6 +178,8 @@ class Measurements : ObservableObject {
162 178
             powerSum += power
163 179
             voltageSum += voltage
164 180
             currentSum += current
181
+            temperatureSum += temperature
182
+            rssiSum += rssi
165 183
             return
166 184
         }
167 185
 
@@ -173,6 +191,8 @@ class Measurements : ObservableObject {
173 191
         powerSum = power
174 192
         voltageSum = voltage
175 193
         currentSum = current
194
+        temperatureSum = temperature
195
+        rssiSum = rssi
176 196
     }
177 197
 
178 198
     func markDiscontinuity(at timestamp: Date) {
@@ -180,6 +200,39 @@ class Measurements : ObservableObject {
180 200
         power.addDiscontinuity(timestamp: timestamp)
181 201
         voltage.addDiscontinuity(timestamp: timestamp)
182 202
         current.addDiscontinuity(timestamp: timestamp)
203
+        temperature.addDiscontinuity(timestamp: timestamp)
204
+        rssi.addDiscontinuity(timestamp: timestamp)
183 205
         self.objectWillChange.send()
184 206
     }
207
+
208
+    func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
209
+        if shouldFlushPendingValues {
210
+            flushPendingValues()
211
+        }
212
+        return power.samplePoints.count
213
+    }
214
+
215
+    func recentPowerPoints(limit: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> [Measurement.Point] {
216
+        if shouldFlushPendingValues {
217
+            flushPendingValues()
218
+        }
219
+
220
+        let samplePoints = power.samplePoints
221
+        guard limit > 0, samplePoints.count > limit else {
222
+            return samplePoints
223
+        }
224
+
225
+        return Array(samplePoints.suffix(limit))
226
+    }
227
+
228
+    func averagePower(forRecentSampleCount sampleCount: Int, flushPendingValues shouldFlushPendingValues: Bool = true) -> Double? {
229
+        let points = recentPowerPoints(limit: sampleCount, flushPendingValues: shouldFlushPendingValues)
230
+        guard !points.isEmpty else { return nil }
231
+
232
+        let sum = points.reduce(0) { partialResult, point in
233
+            partialResult + point.value
234
+        }
235
+
236
+        return sum / Double(points.count)
237
+    }
185 238
 }
+8 -1
USB Meter/Model/Meter.swift
@@ -743,7 +743,14 @@ class Meter : NSObject, ObservableObject, Identifiable {
743 743
             }
744 744
         }
745 745
         updateChargeRecord(at: dataDumpRequestTimestamp)
746
-        measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
746
+        measurements.addValues(
747
+            timestamp: dataDumpRequestTimestamp,
748
+            power: power,
749
+            voltage: voltage,
750
+            current: current,
751
+            temperature: displayedTemperatureValue,
752
+            rssi: Double(btSerial.averageRSSI)
753
+        )
747 754
 //        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
748 755
 //            //track("\(name) - Scheduled new request.")
749 756
 //        }
+81 -5
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -13,12 +13,14 @@ struct MeasurementChartView: View {
13 13
         case power
14 14
         case voltage
15 15
         case current
16
+        case temperature
16 17
 
17 18
         var unit: String {
18 19
             switch self {
19 20
             case .power: return "W"
20 21
             case .voltage: return "V"
21 22
             case .current: return "A"
23
+            case .temperature: return ""
22 24
             }
23 25
         }
24 26
 
@@ -27,6 +29,7 @@ struct MeasurementChartView: View {
27 29
             case .power: return .red
28 30
             case .voltage: return .green
29 31
             case .current: return .blue
32
+            case .temperature: return .orange
30 33
             }
31 34
         }
32 35
     }
@@ -45,6 +48,7 @@ struct MeasurementChartView: View {
45 48
     private let minimumVoltageSpan = 0.5
46 49
     private let minimumCurrentSpan = 0.5
47 50
     private let minimumPowerSpan = 0.5
51
+    private let minimumTemperatureSpan = 1.0
48 52
     private let defaultEmptyChartTimeSpan: TimeInterval = 60
49 53
 
50 54
     let compactLayout: Bool
@@ -58,6 +62,7 @@ struct MeasurementChartView: View {
58 62
     @State var displayVoltage: Bool = false
59 63
     @State var displayCurrent: Bool = false
60 64
     @State var displayPower: Bool = true
65
+    @State var displayTemperature: Bool = false
61 66
     @State private var showResetConfirmation: Bool = false
62 67
     @State private var chartNow: Date = Date()
63 68
     @State private var pinOrigin: Bool = false
@@ -67,6 +72,7 @@ struct MeasurementChartView: View {
67 72
     @State private var powerAxisOrigin: Double = 0
68 73
     @State private var voltageAxisOrigin: Double = 0
69 74
     @State private var currentAxisOrigin: Double = 0
75
+    @State private var temperatureAxisOrigin: Double = 0
70 76
     let xLabels: Int = 4
71 77
     let yLabels: Int = 4
72 78
 
@@ -182,6 +188,7 @@ struct MeasurementChartView: View {
182 188
         let powerSeries = series(for: measurements.power, kind: .power, minimumYSpan: minimumPowerSpan)
183 189
         let voltageSeries = series(for: measurements.voltage, kind: .voltage, minimumYSpan: minimumVoltageSpan)
184 190
         let currentSeries = series(for: measurements.current, kind: .current, minimumYSpan: minimumCurrentSpan)
191
+        let temperatureSeries = series(for: measurements.temperature, kind: .temperature, minimumYSpan: minimumTemperatureSpan)
185 192
         let primarySeries = displayedPrimarySeries(
186 193
             powerSeries: powerSeries,
187 194
             voltageSeries: voltageSeries,
@@ -219,7 +226,8 @@ struct MeasurementChartView: View {
219 226
                                     renderedChart(
220 227
                                         powerSeries: powerSeries,
221 228
                                         voltageSeries: voltageSeries,
222
-                                        currentSeries: currentSeries
229
+                                        currentSeries: currentSeries,
230
+                                        temperatureSeries: temperatureSeries
223 231
                                     )
224 232
                                 }
225 233
                                 .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
@@ -230,7 +238,8 @@ struct MeasurementChartView: View {
230 238
                                     height: plotHeight,
231 239
                                     powerSeries: powerSeries,
232 240
                                     voltageSeries: voltageSeries,
233
-                                    currentSeries: currentSeries
241
+                                    currentSeries: currentSeries,
242
+                                    temperatureSeries: temperatureSeries
234 243
                                 )
235 244
                                 .frame(width: axisColumnWidth, height: plotHeight)
236 245
                             }
@@ -377,6 +386,9 @@ struct MeasurementChartView: View {
377 386
                 displayVoltage.toggle()
378 387
                 if displayVoltage {
379 388
                     displayPower = false
389
+                    if displayTemperature && displayCurrent {
390
+                        displayCurrent = false
391
+                    }
380 392
                 }
381 393
             }
382 394
 
@@ -384,6 +396,9 @@ struct MeasurementChartView: View {
384 396
                 displayCurrent.toggle()
385 397
                 if displayCurrent {
386 398
                     displayPower = false
399
+                    if displayTemperature && displayVoltage {
400
+                        displayVoltage = false
401
+                    }
387 402
                 }
388 403
             }
389 404
 
@@ -394,6 +409,13 @@ struct MeasurementChartView: View {
394 409
                     displayVoltage = false
395 410
                 }
396 411
             }
412
+
413
+            seriesToggleButton(title: "Temp", isOn: displayTemperature, condensedLayout: condensedLayout) {
414
+                displayTemperature.toggle()
415
+                if displayTemperature && displayVoltage && displayCurrent {
416
+                    displayCurrent = false
417
+                }
418
+            }
397 419
         }
398 420
     }
399 421
 
@@ -596,7 +618,8 @@ struct MeasurementChartView: View {
596 618
     private func renderedChart(
597 619
         powerSeries: SeriesData,
598 620
         voltageSeries: SeriesData,
599
-        currentSeries: SeriesData
621
+        currentSeries: SeriesData,
622
+        temperatureSeries: SeriesData
600 623
     ) -> some View {
601 624
         if self.displayPower {
602 625
             Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
@@ -611,6 +634,11 @@ struct MeasurementChartView: View {
611 634
                     .opacity(0.78)
612 635
             }
613 636
         }
637
+
638
+        if displayTemperature {
639
+            Chart(points: temperatureSeries.points, context: temperatureSeries.context, strokeColor: .orange)
640
+                .opacity(0.86)
641
+        }
614 642
     }
615 643
 
616 644
     @ViewBuilder
@@ -618,9 +646,18 @@ struct MeasurementChartView: View {
618 646
         height: CGFloat,
619 647
         powerSeries: SeriesData,
620 648
         voltageSeries: SeriesData,
621
-        currentSeries: SeriesData
649
+        currentSeries: SeriesData,
650
+        temperatureSeries: SeriesData
622 651
     ) -> some View {
623
-        if displayVoltage && displayCurrent {
652
+        if displayTemperature {
653
+            yAxisLabelsView(
654
+                height: height,
655
+                context: temperatureSeries.context,
656
+                seriesKind: .temperature,
657
+                measurementUnit: measurementUnit(for: .temperature),
658
+                tint: temperatureSeries.kind.tint
659
+            )
660
+        } else if displayVoltage && displayCurrent {
624 661
             yAxisLabelsView(
625 662
                 height: height,
626 663
                 context: currentSeries.context,
@@ -785,6 +822,9 @@ struct MeasurementChartView: View {
785 822
             if displayCurrent {
786 823
                 currentAxisOrigin = 0
787 824
             }
825
+            if displayTemperature {
826
+                temperatureAxisOrigin = 0
827
+            }
788 828
         }
789 829
 
790 830
         pinOrigin = true
@@ -797,6 +837,7 @@ struct MeasurementChartView: View {
797 837
         powerAxisOrigin = displayedLowerBoundForSeries(.power)
798 838
         voltageAxisOrigin = voltageSeries.autoLowerBound
799 839
         currentAxisOrigin = currentSeries.autoLowerBound
840
+        temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
800 841
         sharedAxisOrigin = min(voltageAxisOrigin, currentAxisOrigin)
801 842
         sharedAxisUpperBound = max(voltageSeries.autoUpperBound, currentSeries.autoUpperBound)
802 843
         ensureSharedScaleSpan()
@@ -816,6 +857,8 @@ struct MeasurementChartView: View {
816 857
                 return sharedAxisOrigin
817 858
             }
818 859
             return pinOrigin ? currentAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.current), minimumYSpan: minimumCurrentSpan).lowerBound
860
+        case .temperature:
861
+            return pinOrigin ? temperatureAxisOrigin : automaticYBounds(for: filteredSamplePoints(measurements.temperature), minimumYSpan: minimumTemperatureSpan).lowerBound
819 862
         }
820 863
     }
821 864
 
@@ -893,6 +936,8 @@ struct MeasurementChartView: View {
893 936
             return voltageAxisOrigin
894 937
         case .current:
895 938
             return currentAxisOrigin
939
+        case .temperature:
940
+            return temperatureAxisOrigin
896 941
         }
897 942
     }
898 943
 
@@ -911,6 +956,10 @@ struct MeasurementChartView: View {
911 956
             return max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
912 957
         }
913 958
 
959
+        if kind == .temperature {
960
+            return autoUpperBound
961
+        }
962
+
914 963
         return max(
915 964
             maximumSampleValue ?? lowerBound,
916 965
             lowerBound + minimumYSpan,
@@ -935,6 +984,8 @@ struct MeasurementChartView: View {
935 984
                 voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
936 985
             case .current:
937 986
                 currentAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .current))
987
+            case .temperature:
988
+                temperatureAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .temperature))
938 989
             }
939 990
         }
940 991
 
@@ -957,6 +1008,8 @@ struct MeasurementChartView: View {
957 1008
                 voltageAxisOrigin = 0
958 1009
             case .current:
959 1010
                 currentAxisOrigin = 0
1011
+            case .temperature:
1012
+                temperatureAxisOrigin = 0
960 1013
             }
961 1014
         }
962 1015
 
@@ -984,6 +1037,8 @@ struct MeasurementChartView: View {
984 1037
             return snappedOriginValue(filteredSamplePoints(measurements.voltage).map(\.value).min() ?? 0)
985 1038
         case .current:
986 1039
             return snappedOriginValue(filteredSamplePoints(measurements.current).map(\.value).min() ?? 0)
1040
+        case .temperature:
1041
+            return snappedOriginValue(filteredSamplePoints(measurements.temperature).map(\.value).min() ?? 0)
987 1042
         }
988 1043
     }
989 1044
 
@@ -994,6 +1049,27 @@ struct MeasurementChartView: View {
994 1049
         )
995 1050
     }
996 1051
 
1052
+    private func measurementUnit(for kind: SeriesKind) -> String {
1053
+        switch kind {
1054
+        case .temperature:
1055
+            let locale = Locale.autoupdatingCurrent
1056
+            if #available(iOS 16.0, *) {
1057
+                switch locale.measurementSystem {
1058
+                case .us:
1059
+                    return "°F"
1060
+                default:
1061
+                    return "°C"
1062
+                }
1063
+            }
1064
+
1065
+            let regionCode = locale.regionCode ?? ""
1066
+            let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
1067
+            return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
1068
+        default:
1069
+            return kind.unit
1070
+        }
1071
+    }
1072
+
997 1073
     private func ensureSharedScaleSpan() {
998 1074
         sharedAxisUpperBound = max(sharedAxisUpperBound, sharedAxisOrigin + minimumSharedScaleSpan)
999 1075
     }
+8 -1
USB Meter/Views/Meter/Tabs/Home/Subviews/MeterConnectionActionView.swift
@@ -47,7 +47,14 @@ struct MeterConnectionToolbarButton: View {
47 47
     }
48 48
 
49 49
     private var systemImage: String {
50
-        connected ? "link.badge.minus" : "bolt.horizontal.circle.fill"
50
+        if connected {
51
+            #if targetEnvironment(macCatalyst)
52
+            return "bolt.slash.circle.fill"
53
+            #else
54
+            return "link.badge.minus"
55
+            #endif
56
+        }
57
+        return "bolt.horizontal.circle.fill"
51 58
     }
52 59
 
53 60
     var body: some View {
+390 -18
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -10,6 +10,8 @@ import SwiftUI
10 10
 
11 11
 struct MeterLiveContentView: View {
12 12
     @EnvironmentObject private var meter: Meter
13
+    @State private var powerAverageSheetVisibility = false
14
+    @State private var rssiHistorySheetVisibility = false
13 15
     var compactLayout: Bool = false
14 16
     var availableSize: CGSize? = nil
15 17
 
@@ -71,7 +73,10 @@ struct MeterLiveContentView: View {
71 73
                             min: meter.measurements.power.context.minValue,
72 74
                             max: meter.measurements.power.context.maxValue,
73 75
                             unit: "W"
74
-                        )
76
+                        ),
77
+                        action: {
78
+                            powerAverageSheetVisibility = true
79
+                        }
75 80
                     )
76 81
                 }
77 82
 
@@ -81,7 +86,10 @@ struct MeterLiveContentView: View {
81 86
                         symbol: "thermometer.medium",
82 87
                         color: .orange,
83 88
                         value: meter.primaryTemperatureDescription,
84
-                        range: temperatureRange()
89
+                        range: temperatureRange(
90
+                            min: meter.measurements.temperature.context.minValue,
91
+                            max: meter.measurements.temperature.context.maxValue
92
+                        )
85 93
                     )
86 94
                 }
87 95
 
@@ -100,13 +108,16 @@ struct MeterLiveContentView: View {
100 108
                     symbol: "dot.radiowaves.left.and.right",
101 109
                     color: .mint,
102 110
                     value: "\(meter.btSerial.averageRSSI) dBm",
103
-                    range: MeterLiveMetricRange(
104
-                        minLabel: "Min",
105
-                        maxLabel: "Max",
106
-                        minValue: "\(meter.btSerial.minRSSI) dBm",
107
-                        maxValue: "\(meter.btSerial.maxRSSI) dBm"
111
+                    range: metricRange(
112
+                        min: meter.measurements.rssi.context.minValue,
113
+                        max: meter.measurements.rssi.context.maxValue,
114
+                        unit: "dBm",
115
+                        decimalDigits: 0
108 116
                     ),
109
-                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold)
117
+                    valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
118
+                    action: {
119
+                        rssiHistorySheetVisibility = true
120
+                    }
110 121
                 )
111 122
 
112 123
                 if meter.supportsChargerDetection && hasLiveMetrics {
@@ -125,6 +136,14 @@ struct MeterLiveContentView: View {
125 136
             }
126 137
         }
127 138
         .frame(maxWidth: .infinity, alignment: .topLeading)
139
+        .sheet(isPresented: $powerAverageSheetVisibility) {
140
+            PowerAverageSheetView(visibility: $powerAverageSheetVisibility)
141
+                .environmentObject(meter.measurements)
142
+        }
143
+        .sheet(isPresented: $rssiHistorySheetVisibility) {
144
+            RSSIHistorySheetView(visibility: $rssiHistorySheetVisibility)
145
+                .environmentObject(meter.measurements)
146
+        }
128 147
     }
129 148
 
130 149
     private var hasLiveMetrics: Bool {
@@ -192,9 +211,10 @@ struct MeterLiveContentView: View {
192 211
         valueFont: Font? = nil,
193 212
         valueLineLimit: Int = 1,
194 213
         valueMonospacedDigits: Bool = true,
195
-        valueMinimumScaleFactor: CGFloat = 0.85
214
+        valueMinimumScaleFactor: CGFloat = 0.85,
215
+        action: (() -> Void)? = nil
196 216
     ) -> some View {
197
-        VStack(alignment: .leading, spacing: 10) {
217
+        let cardContent = VStack(alignment: .leading, spacing: 10) {
198 218
             HStack(spacing: compactLayout ? 8 : 10) {
199 219
                 Group {
200 220
                     if let customSymbol {
@@ -246,6 +266,17 @@ struct MeterLiveContentView: View {
246 266
         )
247 267
         .padding(compactLayout ? 12 : 16)
248 268
         .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
269
+
270
+        if let action {
271
+            return AnyView(
272
+                Button(action: action) {
273
+                    cardContent
274
+                }
275
+                .buttonStyle(.plain)
276
+            )
277
+        }
278
+
279
+        return AnyView(cardContent)
249 280
     }
250 281
 
251 282
     private func metricRangeTable(_ range: MeterLiveMetricRange) -> some View {
@@ -270,26 +301,27 @@ struct MeterLiveContentView: View {
270 301
         }
271 302
     }
272 303
 
273
-    private func metricRange(min: Double, max: Double, unit: String) -> MeterLiveMetricRange? {
304
+    private func metricRange(min: Double, max: Double, unit: String, decimalDigits: Int = 3) -> MeterLiveMetricRange? {
274 305
         guard min.isFinite, max.isFinite else { return nil }
275 306
 
276 307
         return MeterLiveMetricRange(
277 308
             minLabel: "Min",
278 309
             maxLabel: "Max",
279
-            minValue: "\(min.format(decimalDigits: 3)) \(unit)",
280
-            maxValue: "\(max.format(decimalDigits: 3)) \(unit)"
310
+            minValue: "\(min.format(decimalDigits: decimalDigits)) \(unit)",
311
+            maxValue: "\(max.format(decimalDigits: decimalDigits)) \(unit)"
281 312
         )
282 313
     }
283 314
 
284
-    private func temperatureRange() -> MeterLiveMetricRange? {
285
-        let value = meter.primaryTemperatureDescription
286
-        guard !value.isEmpty else { return nil }
315
+    private func temperatureRange(min: Double, max: Double) -> MeterLiveMetricRange? {
316
+        guard min.isFinite, max.isFinite else { return nil }
317
+
318
+        let unitSuffix = temperatureUnitSuffix()
287 319
 
288 320
         return MeterLiveMetricRange(
289 321
             minLabel: "Min",
290 322
             maxLabel: "Max",
291
-            minValue: value,
292
-            maxValue: value
323
+            minValue: "\(min.format(decimalDigits: 0))\(unitSuffix)",
324
+            maxValue: "\(max.format(decimalDigits: 0))\(unitSuffix)"
293 325
         )
294 326
     }
295 327
 
@@ -299,4 +331,344 @@ struct MeterLiveContentView: View {
299 331
         }
300 332
         return date.format(as: "yyyy-MM-dd HH:mm")
301 333
     }
334
+
335
+    private func temperatureUnitSuffix() -> String {
336
+        if meter.supportsManualTemperatureUnitSelection {
337
+            return "°"
338
+        }
339
+
340
+        let locale = Locale.autoupdatingCurrent
341
+        if #available(iOS 16.0, *) {
342
+            switch locale.measurementSystem {
343
+            case .us:
344
+                return "°F"
345
+            default:
346
+                return "°C"
347
+            }
348
+        }
349
+
350
+        let regionCode = locale.regionCode ?? ""
351
+        let fahrenheitRegions: Set<String> = ["US", "BS", "BZ", "KY", "PW", "LR", "FM", "MH"]
352
+        return fahrenheitRegions.contains(regionCode) ? "°F" : "°C"
353
+    }
354
+}
355
+
356
+private struct PowerAverageSheetView: View {
357
+    @EnvironmentObject private var measurements: Measurements
358
+
359
+    @Binding var visibility: Bool
360
+
361
+    @State private var selectedSampleCount: Int = 20
362
+
363
+    var body: some View {
364
+        let bufferedSamples = measurements.powerSampleCount()
365
+
366
+        NavigationView {
367
+            ScrollView {
368
+                VStack(alignment: .leading, spacing: 14) {
369
+                    VStack(alignment: .leading, spacing: 8) {
370
+                        Text("Power Average")
371
+                            .font(.system(.title3, design: .rounded).weight(.bold))
372
+                        Text("Inspect the recent power buffer, choose how many values to include, and compute the average power over that window.")
373
+                            .font(.footnote)
374
+                            .foregroundColor(.secondary)
375
+                    }
376
+                    .padding(18)
377
+                    .meterCard(tint: .pink, fillOpacity: 0.18, strokeOpacity: 0.24)
378
+
379
+                    MeterInfoCardView(title: "Average Calculator", tint: .pink) {
380
+                        if bufferedSamples == 0 {
381
+                            Text("No power samples are available yet.")
382
+                                .font(.footnote)
383
+                                .foregroundColor(.secondary)
384
+                        } else {
385
+                            VStack(alignment: .leading, spacing: 14) {
386
+                                VStack(alignment: .leading, spacing: 8) {
387
+                                    Text("Values used")
388
+                                        .font(.subheadline.weight(.semibold))
389
+
390
+                                    Picker("Values used", selection: selectedSampleCountBinding(bufferedSamples: bufferedSamples)) {
391
+                                        ForEach(availableSampleOptions(bufferedSamples: bufferedSamples), id: \.self) { option in
392
+                                            Text(sampleOptionTitle(option, bufferedSamples: bufferedSamples)).tag(option)
393
+                                        }
394
+                                    }
395
+                                    .pickerStyle(.menu)
396
+                                }
397
+
398
+                                VStack(alignment: .leading, spacing: 6) {
399
+                                    Text(averagePowerLabel(bufferedSamples: bufferedSamples))
400
+                                        .font(.system(.title2, design: .rounded).weight(.bold))
401
+                                        .monospacedDigit()
402
+
403
+                                    Text("Buffered samples: \(bufferedSamples)")
404
+                                        .font(.caption)
405
+                                        .foregroundColor(.secondary)
406
+                                }
407
+                            }
408
+                        }
409
+                    }
410
+
411
+                    MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
412
+                        Text("Reset clears the captured live measurement buffer for power, voltage, current, temperature, and RSSI.")
413
+                            .font(.footnote)
414
+                            .foregroundColor(.secondary)
415
+
416
+                        Button("Reset Buffer") {
417
+                            measurements.resetSeries()
418
+                            selectedSampleCount = defaultSampleCount(bufferedSamples: measurements.powerSampleCount(flushPendingValues: false))
419
+                        }
420
+                        .foregroundColor(.red)
421
+                    }
422
+                }
423
+                .padding()
424
+            }
425
+            .background(
426
+                LinearGradient(
427
+                    colors: [.pink.opacity(0.14), Color.clear],
428
+                    startPoint: .topLeading,
429
+                    endPoint: .bottomTrailing
430
+                )
431
+                .ignoresSafeArea()
432
+            )
433
+            .navigationBarItems(
434
+                leading: Button("Done") { visibility.toggle() }
435
+            )
436
+            .navigationBarTitle("Power", displayMode: .inline)
437
+        }
438
+        .navigationViewStyle(StackNavigationViewStyle())
439
+        .onAppear {
440
+            selectedSampleCount = defaultSampleCount(bufferedSamples: bufferedSamples)
441
+        }
442
+        .onChange(of: bufferedSamples) { newValue in
443
+            selectedSampleCount = min(max(1, selectedSampleCount), max(1, newValue))
444
+        }
445
+    }
446
+
447
+    private func availableSampleOptions(bufferedSamples: Int) -> [Int] {
448
+        guard bufferedSamples > 0 else { return [] }
449
+
450
+        let filtered = measurements.averagePowerSampleOptions.filter { $0 < bufferedSamples }
451
+        return (filtered + [bufferedSamples]).sorted()
452
+    }
453
+
454
+    private func defaultSampleCount(bufferedSamples: Int) -> Int {
455
+        guard bufferedSamples > 0 else { return 20 }
456
+        return min(20, bufferedSamples)
457
+    }
458
+
459
+    private func selectedSampleCountBinding(bufferedSamples: Int) -> Binding<Int> {
460
+        Binding(
461
+            get: {
462
+                let availableOptions = availableSampleOptions(bufferedSamples: bufferedSamples)
463
+                guard !availableOptions.isEmpty else { return defaultSampleCount(bufferedSamples: bufferedSamples) }
464
+                if availableOptions.contains(selectedSampleCount) {
465
+                    return selectedSampleCount
466
+                }
467
+                return min(availableOptions.last ?? bufferedSamples, defaultSampleCount(bufferedSamples: bufferedSamples))
468
+            },
469
+            set: { newValue in
470
+                selectedSampleCount = newValue
471
+            }
472
+        )
473
+    }
474
+
475
+    private func sampleOptionTitle(_ option: Int, bufferedSamples: Int) -> String {
476
+        if option == bufferedSamples {
477
+            return "All (\(option))"
478
+        }
479
+        return "\(option) values"
480
+    }
481
+
482
+    private func averagePowerLabel(bufferedSamples: Int) -> String {
483
+        guard let average = measurements.averagePower(forRecentSampleCount: selectedSampleCount, flushPendingValues: false) else {
484
+            return "No data"
485
+        }
486
+
487
+        let effectiveSampleCount = min(selectedSampleCount, bufferedSamples)
488
+        return "\(average.format(decimalDigits: 3)) W avg (\(effectiveSampleCount))"
489
+    }
490
+}
491
+
492
+private struct RSSIHistorySheetView: View {
493
+    @EnvironmentObject private var measurements: Measurements
494
+
495
+    @Binding var visibility: Bool
496
+
497
+    private let xLabels: Int = 4
498
+    private let yLabels: Int = 4
499
+
500
+    var body: some View {
501
+        let points = measurements.rssi.points
502
+        let samplePoints = measurements.rssi.samplePoints
503
+        let chartContext = buildChartContext(for: samplePoints)
504
+
505
+        NavigationView {
506
+            ScrollView {
507
+                VStack(alignment: .leading, spacing: 14) {
508
+                    VStack(alignment: .leading, spacing: 8) {
509
+                        Text("RSSI History")
510
+                            .font(.system(.title3, design: .rounded).weight(.bold))
511
+                        Text("Signal strength captured over time while the meter stays connected.")
512
+                            .font(.footnote)
513
+                            .foregroundColor(.secondary)
514
+                    }
515
+                    .padding(18)
516
+                    .meterCard(tint: .mint, fillOpacity: 0.18, strokeOpacity: 0.24)
517
+
518
+                    if samplePoints.isEmpty {
519
+                        Text("No RSSI samples have been captured yet.")
520
+                            .font(.footnote)
521
+                            .foregroundColor(.secondary)
522
+                            .padding(18)
523
+                            .meterCard(tint: .secondary, fillOpacity: 0.14, strokeOpacity: 0.20)
524
+                    } else {
525
+                        MeterInfoCardView(title: "Signal Chart", tint: .mint) {
526
+                            VStack(alignment: .leading, spacing: 12) {
527
+                                HStack(spacing: 12) {
528
+                                    signalSummaryChip(title: "Current", value: "\(Int(samplePoints.last?.value ?? 0)) dBm")
529
+                                    signalSummaryChip(title: "Min", value: "\(Int(samplePoints.map(\.value).min() ?? 0)) dBm")
530
+                                    signalSummaryChip(title: "Max", value: "\(Int(samplePoints.map(\.value).max() ?? 0)) dBm")
531
+                                }
532
+
533
+                                HStack(spacing: 8) {
534
+                                    rssiYAxisView(context: chartContext)
535
+                                        .frame(width: 52, height: 220)
536
+
537
+                                    ZStack {
538
+                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
539
+                                            .fill(Color.primary.opacity(0.05))
540
+
541
+                                        RoundedRectangle(cornerRadius: 18, style: .continuous)
542
+                                            .stroke(Color.secondary.opacity(0.16), lineWidth: 1)
543
+
544
+                                        rssiHorizontalGuides(context: chartContext)
545
+                                        rssiVerticalGuides(context: chartContext)
546
+                                        Chart(points: points, context: chartContext, strokeColor: .mint)
547
+                                            .opacity(0.82)
548
+                                    }
549
+                                    .frame(maxWidth: .infinity)
550
+                                    .frame(height: 220)
551
+                                }
552
+
553
+                                rssiXAxisLabelsView(context: chartContext)
554
+                                    .frame(height: 28)
555
+                            }
556
+                        }
557
+                    }
558
+                }
559
+                .padding()
560
+            }
561
+            .background(
562
+                LinearGradient(
563
+                    colors: [.mint.opacity(0.14), Color.clear],
564
+                    startPoint: .topLeading,
565
+                    endPoint: .bottomTrailing
566
+                )
567
+                .ignoresSafeArea()
568
+            )
569
+            .navigationBarItems(
570
+                leading: Button("Done") { visibility.toggle() }
571
+            )
572
+            .navigationBarTitle("RSSI", displayMode: .inline)
573
+        }
574
+        .navigationViewStyle(StackNavigationViewStyle())
575
+    }
576
+
577
+    private func buildChartContext(for samplePoints: [Measurements.Measurement.Point]) -> ChartContext {
578
+        let context = ChartContext()
579
+        let upperBound = max(samplePoints.last?.timestamp ?? Date(), Date())
580
+        let lowerBound = samplePoints.first?.timestamp ?? upperBound.addingTimeInterval(-60)
581
+        let minimumValue = samplePoints.map(\.value).min() ?? -100
582
+        let maximumValue = samplePoints.map(\.value).max() ?? -40
583
+        let padding = max((maximumValue - minimumValue) * 0.12, 4)
584
+
585
+        context.setBounds(
586
+            xMin: CGFloat(lowerBound.timeIntervalSince1970),
587
+            xMax: CGFloat(max(upperBound.timeIntervalSince1970, lowerBound.timeIntervalSince1970 + 1)),
588
+            yMin: CGFloat(minimumValue - padding),
589
+            yMax: CGFloat(maximumValue + padding)
590
+        )
591
+        return context
592
+    }
593
+
594
+    private func signalSummaryChip(title: String, value: String) -> some View {
595
+        VStack(alignment: .leading, spacing: 4) {
596
+            Text(title)
597
+                .font(.caption.weight(.semibold))
598
+                .foregroundColor(.secondary)
599
+            Text(value)
600
+                .font(.subheadline.weight(.bold))
601
+                .monospacedDigit()
602
+        }
603
+        .padding(.horizontal, 12)
604
+        .padding(.vertical, 10)
605
+        .meterCard(tint: .mint, fillOpacity: 0.10, strokeOpacity: 0.14, cornerRadius: 14)
606
+    }
607
+
608
+    private func rssiXAxisLabelsView(context: ChartContext) -> some View {
609
+        let labels = (1...xLabels).map {
610
+            Date(timeIntervalSince1970: context.xAxisLabel(for: $0, of: xLabels)).format(as: "HH:mm:ss")
611
+        }
612
+
613
+        return HStack {
614
+            ForEach(Array(labels.enumerated()), id: \.offset) { item in
615
+                Text(item.element)
616
+                    .font(.caption2.weight(.semibold))
617
+                    .monospacedDigit()
618
+                    .frame(maxWidth: .infinity)
619
+            }
620
+        }
621
+        .foregroundColor(.secondary)
622
+    }
623
+
624
+    private func rssiYAxisView(context: ChartContext) -> some View {
625
+        VStack(spacing: 0) {
626
+            ForEach((1...yLabels).reversed(), id: \.self) { labelIndex in
627
+                Spacer(minLength: 0)
628
+                Text("\(context.yAxisLabel(for: labelIndex, of: yLabels).format(decimalDigits: 0))")
629
+                    .font(.caption2.weight(.semibold))
630
+                    .monospacedDigit()
631
+                    .foregroundColor(.primary)
632
+                Spacer(minLength: 0)
633
+            }
634
+        }
635
+        .padding(.vertical, 12)
636
+        .background(
637
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
638
+                .fill(Color.mint.opacity(0.12))
639
+        )
640
+        .overlay(
641
+            RoundedRectangle(cornerRadius: 16, style: .continuous)
642
+                .stroke(Color.mint.opacity(0.20), lineWidth: 1)
643
+        )
644
+    }
645
+
646
+    private func rssiHorizontalGuides(context: ChartContext) -> some View {
647
+        GeometryReader { geometry in
648
+            Path { path in
649
+                for labelIndex in 1...yLabels {
650
+                    let value = context.yAxisLabel(for: labelIndex, of: yLabels)
651
+                    let anchorPoint = CGPoint(x: context.origin.x, y: CGFloat(value))
652
+                    let y = context.placeInRect(point: anchorPoint).y * geometry.size.height
653
+                    path.addLine(from: CGPoint(x: 0, y: y), to: CGPoint(x: geometry.size.width, y: y))
654
+                }
655
+            }
656
+            .stroke(Color.secondary.opacity(0.30), lineWidth: 0.8)
657
+        }
658
+    }
659
+
660
+    private func rssiVerticalGuides(context: ChartContext) -> some View {
661
+        GeometryReader { geometry in
662
+            Path { path in
663
+                for labelIndex in 2..<xLabels {
664
+                    let value = context.xAxisLabel(for: labelIndex, of: xLabels)
665
+                    let anchorPoint = CGPoint(x: CGFloat(value), y: context.origin.y)
666
+                    let x = context.placeInRect(point: anchorPoint).x * geometry.size.width
667
+                    path.move(to: CGPoint(x: x, y: 0))
668
+                    path.addLine(to: CGPoint(x: x, y: geometry.size.height))
669
+                }
670
+            }
671
+            .stroke(Color.secondary.opacity(0.26), style: StrokeStyle(lineWidth: 0.8, dash: [4, 4]))
672
+        }
673
+    }
302 674
 }