Showing 8 changed files with 837 additions and 88 deletions
+9 -0
USB Meter/Model/BluetoothManager.swift
@@ -115,10 +115,14 @@ extension BluetoothManager : CBCentralManagerDelegate {
115 115
     func centralManagerDidUpdateState(_ central: CBCentralManager) {
116 116
         managerState = central.state;
117 117
         track("\(central.state)")
118
+        for meter in appData.meters.values {
119
+            meter.btSerial.centralStateChanged(to: central.state)
120
+        }
118 121
         
119 122
         switch central.state {
120 123
         case .poweredOff:
121 124
             scanStartedAt = nil
125
+            advertisementDataCache.clear()
122 126
             track("Bluetooth is Off. How should I behave?")
123 127
         case .poweredOn:
124 128
             scanStartedAt = Date()
@@ -129,18 +133,23 @@ extension BluetoothManager : CBCentralManagerDelegate {
129 133
             scanForMeters()
130 134
         case .resetting:
131 135
             scanStartedAt = nil
136
+            advertisementDataCache.clear()
132 137
             track("Bluetooth is reseting... . Whatever that means.")
133 138
         case .unauthorized:
134 139
             scanStartedAt = nil
140
+            advertisementDataCache.clear()
135 141
             track("Bluetooth is not authorized.")
136 142
         case .unknown:
137 143
             scanStartedAt = nil
144
+            advertisementDataCache.clear()
138 145
             track("Bluetooth is in an unknown state.")
139 146
         case .unsupported:
140 147
             scanStartedAt = nil
148
+            advertisementDataCache.clear()
141 149
             track("Bluetooth not supported by device")
142 150
         default:
143 151
             scanStartedAt = nil
152
+            advertisementDataCache.clear()
144 153
             track("Bluetooth is in a state never seen before!")
145 154
         }
146 155
     }
+38 -0
USB Meter/Model/BluetoothSerial.swift
@@ -108,9 +108,22 @@ final class BluetoothSerial : NSObject, ObservableObject {
108 108
             notifyCharacteristic = nil
109 109
         }
110 110
     }
111
+
112
+    private func forceNotConnected(reason: String, clearCharacteristics: Bool = true) {
113
+        resetCommunicationState(reason: reason, clearCharacteristics: clearCharacteristics)
114
+        guard operationalState != .peripheralNotConnected else {
115
+            return
116
+        }
117
+        operationalState = .peripheralNotConnected
118
+    }
111 119
     
112 120
     func connect() {
113 121
         administrativeState = .up
122
+        guard manager.state == .poweredOn else {
123
+            track("Connect requested for '\(peripheral.identifier)' but central state is \(manager.state)")
124
+            forceNotConnected(reason: "connect() while central is \(manager.state)")
125
+            return
126
+        }
114 127
         if operationalState < .peripheralConnected {
115 128
             resetCommunicationState(reason: "connect()", clearCharacteristics: true)
116 129
             operationalState = .peripheralConnectionPending
@@ -126,6 +139,11 @@ final class BluetoothSerial : NSObject, ObservableObject {
126 139
         resetCommunicationState(reason: "disconnect()", clearCharacteristics: true)
127 140
         if peripheral.state != .disconnected || operationalState != .peripheralNotConnected {
128 141
             track("Disconnect requested for '\(peripheral.identifier)' while peripheral state is \(peripheral.state.rawValue)")
142
+            guard manager.state == .poweredOn else {
143
+                track("Skipping central cancel for '\(peripheral.identifier)' because central state is \(manager.state)")
144
+                forceNotConnected(reason: "disconnect() while central is \(manager.state)", clearCharacteristics: false)
145
+                return
146
+            }
129 147
             manager.cancelPeripheralConnection(peripheral)
130 148
         }
131 149
     }
@@ -186,6 +204,26 @@ final class BluetoothSerial : NSObject, ObservableObject {
186 204
         operationalState = .peripheralNotConnected
187 205
     }
188 206
 
207
+    func centralStateChanged(to newState: CBManagerState) {
208
+        switch newState {
209
+        case .poweredOn:
210
+            if administrativeState == .up,
211
+               operationalState == .peripheralNotConnected,
212
+               peripheral.state == .disconnected {
213
+                track("Central returned to poweredOn. Restoring connection to '\(peripheral.identifier)'")
214
+                connect()
215
+            }
216
+        case .poweredOff, .resetting, .unauthorized, .unknown, .unsupported:
217
+            if operationalState != .peripheralNotConnected || expectedResponseLength != 0 || !buffer.isEmpty {
218
+                track("Central changed to \(newState). Forcing '\(peripheral.identifier)' to not connected.")
219
+            }
220
+            forceNotConnected(reason: "centralStateChanged(\(newState))")
221
+        @unknown default:
222
+            track("Central changed to an unknown state. Forcing '\(peripheral.identifier)' to not connected.")
223
+            forceNotConnected(reason: "centralStateChanged(@unknown default)")
224
+        }
225
+    }
226
+
189 227
     func setWDT() {
190 228
         wdTimer?.invalidate()
191 229
         wdTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false, block: {_ in
+147 -0
USB Meter/Model/Measurements.swift
@@ -43,6 +43,15 @@ class Measurements : ObservableObject {
43 43
             points.filter { $0.isSample }
44 44
         }
45 45
 
46
+        func points(in range: ClosedRange<Date>) -> [Point] {
47
+            guard !points.isEmpty else { return [] }
48
+
49
+            let startIndex = indexOfFirstPoint(onOrAfter: range.lowerBound)
50
+            let endIndex = indexOfFirstPoint(after: range.upperBound)
51
+            guard startIndex < endIndex else { return [] }
52
+            return Array(points[startIndex..<endIndex])
53
+        }
54
+
46 55
         private func rebuildContext() {
47 56
             context.reset()
48 57
             for point in points where point.isSample {
@@ -60,6 +69,7 @@ class Measurements : ObservableObject {
60 69
         }
61 70
 
62 71
         func removeValue(index: Int) {
72
+            guard points.indices.contains(index) else { return }
63 73
             points.remove(at: index)
64 74
             for index in points.indices {
65 75
                 points[index].id = index
@@ -94,18 +104,100 @@ class Measurements : ObservableObject {
94 104
             rebuildContext()
95 105
             self.objectWillChange.send()
96 106
         }
107
+
108
+        func filterSamples(keeping shouldKeepSampleAt: (Date) -> Bool) {
109
+            let originalSamples = samplePoints
110
+            guard !originalSamples.isEmpty else { return }
111
+
112
+            var rebuiltPoints: [Point] = []
113
+            var lastKeptSampleIndex: Int?
114
+
115
+            for (sampleIndex, sample) in originalSamples.enumerated() where shouldKeepSampleAt(sample.timestamp) {
116
+                if let lastKeptSampleIndex {
117
+                    let hasRemovedSamplesBetween = sampleIndex - lastKeptSampleIndex > 1
118
+                    let previousSample = originalSamples[lastKeptSampleIndex]
119
+                    let originalHadDiscontinuityBetween = points.contains { point in
120
+                        point.isDiscontinuity &&
121
+                        point.timestamp > previousSample.timestamp &&
122
+                        point.timestamp <= sample.timestamp
123
+                    }
124
+
125
+                    if hasRemovedSamplesBetween || originalHadDiscontinuityBetween {
126
+                        rebuiltPoints.append(
127
+                            Point(
128
+                                id: rebuiltPoints.count,
129
+                                timestamp: sample.timestamp,
130
+                                value: rebuiltPoints.last?.value ?? sample.value,
131
+                                kind: .discontinuity
132
+                            )
133
+                        )
134
+                    }
135
+                }
136
+
137
+                rebuiltPoints.append(
138
+                    Point(
139
+                        id: rebuiltPoints.count,
140
+                        timestamp: sample.timestamp,
141
+                        value: sample.value,
142
+                        kind: .sample
143
+                    )
144
+                )
145
+                lastKeptSampleIndex = sampleIndex
146
+            }
147
+
148
+            points = rebuiltPoints
149
+            rebuildContext()
150
+            self.objectWillChange.send()
151
+        }
152
+
153
+        private func indexOfFirstPoint(onOrAfter date: Date) -> Int {
154
+            var lowerBound = 0
155
+            var upperBound = points.count
156
+
157
+            while lowerBound < upperBound {
158
+                let midIndex = (lowerBound + upperBound) / 2
159
+                if points[midIndex].timestamp < date {
160
+                    lowerBound = midIndex + 1
161
+                } else {
162
+                    upperBound = midIndex
163
+                }
164
+            }
165
+
166
+            return lowerBound
167
+        }
168
+
169
+        private func indexOfFirstPoint(after date: Date) -> Int {
170
+            var lowerBound = 0
171
+            var upperBound = points.count
172
+
173
+            while lowerBound < upperBound {
174
+                let midIndex = (lowerBound + upperBound) / 2
175
+                if points[midIndex].timestamp <= date {
176
+                    lowerBound = midIndex + 1
177
+                } else {
178
+                    upperBound = midIndex
179
+                }
180
+            }
181
+
182
+            return lowerBound
183
+        }
97 184
     }
98 185
     
99 186
     @Published var power = Measurement()
100 187
     @Published var voltage = Measurement()
101 188
     @Published var current = Measurement()
102 189
     @Published var temperature = Measurement()
190
+    @Published var energy = Measurement()
103 191
     @Published var rssi = Measurement()
104 192
 
105 193
     let averagePowerSampleOptions: [Int] = [5, 10, 20, 50, 100, 250]
106 194
 
107 195
     private var pendingBucketSecond: Int?
108 196
     private var pendingBucketTimestamp: Date?
197
+    private let energyResetEpsilon = 0.0005
198
+    private var lastEnergyCounterValue: Double?
199
+    private var lastEnergyGroupID: UInt8?
200
+    private var accumulatedEnergyValue: Double = 0
109 201
     
110 202
     private var itemsInSum: Double = 0
111 203
     private var powerSum: Double = 0
@@ -141,8 +233,12 @@ class Measurements : ObservableObject {
141 233
         voltage.resetSeries()
142 234
         current.resetSeries()
143 235
         temperature.resetSeries()
236
+        energy.resetSeries()
144 237
         rssi.resetSeries()
145 238
         resetPendingAggregation()
239
+        lastEnergyCounterValue = nil
240
+        lastEnergyGroupID = nil
241
+        accumulatedEnergyValue = 0
146 242
         self.objectWillChange.send()
147 243
     }
148 244
 
@@ -155,7 +251,11 @@ class Measurements : ObservableObject {
155 251
         voltage.removeValue(index: idx)
156 252
         current.removeValue(index: idx)
157 253
         temperature.removeValue(index: idx)
254
+        energy.removeValue(index: idx)
158 255
         rssi.removeValue(index: idx)
256
+        lastEnergyCounterValue = nil
257
+        lastEnergyGroupID = nil
258
+        accumulatedEnergyValue = 0
159 259
         self.objectWillChange.send()
160 260
     }
161 261
 
@@ -165,7 +265,39 @@ class Measurements : ObservableObject {
165 265
         voltage.trim(before: cutoff)
166 266
         current.trim(before: cutoff)
167 267
         temperature.trim(before: cutoff)
268
+        energy.trim(before: cutoff)
168 269
         rssi.trim(before: cutoff)
270
+        lastEnergyCounterValue = nil
271
+        lastEnergyGroupID = nil
272
+        accumulatedEnergyValue = 0
273
+        self.objectWillChange.send()
274
+    }
275
+
276
+    func keepOnly(in range: ClosedRange<Date>) {
277
+        flushPendingValues()
278
+        power.filterSamples { range.contains($0) }
279
+        voltage.filterSamples { range.contains($0) }
280
+        current.filterSamples { range.contains($0) }
281
+        temperature.filterSamples { range.contains($0) }
282
+        energy.filterSamples { range.contains($0) }
283
+        rssi.filterSamples { range.contains($0) }
284
+        lastEnergyCounterValue = nil
285
+        lastEnergyGroupID = nil
286
+        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
287
+        self.objectWillChange.send()
288
+    }
289
+
290
+    func removeValues(in range: ClosedRange<Date>) {
291
+        flushPendingValues()
292
+        power.filterSamples { !range.contains($0) }
293
+        voltage.filterSamples { !range.contains($0) }
294
+        current.filterSamples { !range.contains($0) }
295
+        temperature.filterSamples { !range.contains($0) }
296
+        energy.filterSamples { !range.contains($0) }
297
+        rssi.filterSamples { !range.contains($0) }
298
+        lastEnergyCounterValue = nil
299
+        lastEnergyGroupID = nil
300
+        accumulatedEnergyValue = energy.samplePoints.last?.value ?? 0
169 301
         self.objectWillChange.send()
170 302
     }
171 303
 
@@ -201,10 +333,25 @@ class Measurements : ObservableObject {
201 333
         voltage.addDiscontinuity(timestamp: timestamp)
202 334
         current.addDiscontinuity(timestamp: timestamp)
203 335
         temperature.addDiscontinuity(timestamp: timestamp)
336
+        energy.addDiscontinuity(timestamp: timestamp)
204 337
         rssi.addDiscontinuity(timestamp: timestamp)
205 338
         self.objectWillChange.send()
206 339
     }
207 340
 
341
+    func captureEnergyValue(timestamp: Date, value: Double, groupID: UInt8) {
342
+        if let lastEnergyCounterValue, lastEnergyGroupID == groupID {
343
+            let delta = value - lastEnergyCounterValue
344
+            if delta > energyResetEpsilon {
345
+                accumulatedEnergyValue += delta
346
+            }
347
+        }
348
+
349
+        energy.addPoint(timestamp: timestamp, value: accumulatedEnergyValue)
350
+        lastEnergyCounterValue = value
351
+        lastEnergyGroupID = groupID
352
+        self.objectWillChange.send()
353
+    }
354
+
208 355
     func powerSampleCount(flushPendingValues shouldFlushPendingValues: Bool = true) -> Int {
209 356
         if shouldFlushPendingValues {
210 357
             flushPendingValues()
+19 -0
USB Meter/Model/Meter.swift
@@ -594,6 +594,18 @@ class Meter : NSObject, ObservableObject, Identifiable {
594 594
         chargeRecordLastPower = 0
595 595
     }
596 596
 
597
+    private func currentEnergySample() -> (groupID: UInt8, value: Double)? {
598
+        guard showsDataGroupEnergy else { return nil }
599
+
600
+        if model == .TC66C && !hasObservedActiveDataGroup {
601
+            return nil
602
+        }
603
+
604
+        let groupID = selectedDataGroup
605
+        guard let record = dataGroupRecords[Int(groupID)] else { return nil }
606
+        return (groupID, record.wh)
607
+    }
608
+
597 609
     private func cancelPendingDataDumpRequest(reason: String) {
598 610
         guard let pendingDataDumpWorkItem else { return }
599 611
         track("\(name) - Cancel scheduled data request (\(reason))")
@@ -743,6 +755,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
743 755
             }
744 756
         }
745 757
         updateChargeRecord(at: dataDumpRequestTimestamp)
758
+        if let energySample = currentEnergySample() {
759
+            measurements.captureEnergyValue(
760
+                timestamp: dataDumpRequestTimestamp,
761
+                value: energySample.value,
762
+                groupID: energySample.groupID
763
+            )
764
+        }
746 765
         measurements.addValues(
747 766
             timestamp: dataDumpRequestTimestamp,
748 767
             power: power,
+595 -86
USB Meter/Views/Meter/Components/MeasurementChartView.swift
@@ -14,8 +14,43 @@ private enum PresentTrackingMode: CaseIterable, Hashable {
14 14
 }
15 15
 
16 16
 struct MeasurementChartView: View {
17
+    private enum SmoothingLevel: CaseIterable, Hashable {
18
+        case off
19
+        case light
20
+        case medium
21
+        case strong
22
+
23
+        var label: String {
24
+            switch self {
25
+            case .off: return "Off"
26
+            case .light: return "Light"
27
+            case .medium: return "Medium"
28
+            case .strong: return "Strong"
29
+            }
30
+        }
31
+
32
+        var shortLabel: String {
33
+            switch self {
34
+            case .off: return "Off"
35
+            case .light: return "Low"
36
+            case .medium: return "Med"
37
+            case .strong: return "High"
38
+            }
39
+        }
40
+
41
+        var movingAverageWindowSize: Int {
42
+            switch self {
43
+            case .off: return 1
44
+            case .light: return 5
45
+            case .medium: return 11
46
+            case .strong: return 21
47
+            }
48
+        }
49
+    }
50
+
17 51
     private enum SeriesKind {
18 52
         case power
53
+        case energy
19 54
         case voltage
20 55
         case current
21 56
         case temperature
@@ -23,6 +58,7 @@ struct MeasurementChartView: View {
23 58
         var unit: String {
24 59
             switch self {
25 60
             case .power: return "W"
61
+            case .energy: return "Wh"
26 62
             case .voltage: return "V"
27 63
             case .current: return "A"
28 64
             case .temperature: return ""
@@ -32,6 +68,7 @@ struct MeasurementChartView: View {
32 68
         var tint: Color {
33 69
             switch self {
34 70
             case .power: return .red
71
+            case .energy: return .teal
35 72
             case .voltage: return .green
36 73
             case .current: return .blue
37 74
             case .temperature: return .orange
@@ -53,8 +90,10 @@ struct MeasurementChartView: View {
53 90
     private let minimumVoltageSpan = 0.5
54 91
     private let minimumCurrentSpan = 0.5
55 92
     private let minimumPowerSpan = 0.5
93
+    private let minimumEnergySpan = 0.1
56 94
     private let minimumTemperatureSpan = 1.0
57 95
     private let defaultEmptyChartTimeSpan: TimeInterval = 60
96
+    private let selectorTint: Color = .blue
58 97
 
59 98
     let compactLayout: Bool
60 99
     let availableSize: CGSize
@@ -67,8 +106,9 @@ struct MeasurementChartView: View {
67 106
     @State var displayVoltage: Bool = false
68 107
     @State var displayCurrent: Bool = false
69 108
     @State var displayPower: Bool = true
109
+    @State var displayEnergy: Bool = false
70 110
     @State var displayTemperature: Bool = false
71
-    @State private var showResetConfirmation: Bool = false
111
+    @State private var smoothingLevel: SmoothingLevel = .off
72 112
     @State private var chartNow: Date = Date()
73 113
     @State private var selectedVisibleTimeRange: ClosedRange<Date>?
74 114
     @State private var isPinnedToPresent: Bool = false
@@ -78,6 +118,7 @@ struct MeasurementChartView: View {
78 118
     @State private var sharedAxisOrigin: Double = 0
79 119
     @State private var sharedAxisUpperBound: Double = 1
80 120
     @State private var powerAxisOrigin: Double = 0
121
+    @State private var energyAxisOrigin: Double = 0
81 122
     @State private var voltageAxisOrigin: Double = 0
82 123
     @State private var currentAxisOrigin: Double = 0
83 124
     @State private var temperatureAxisOrigin: Double = 0
@@ -201,6 +242,12 @@ struct MeasurementChartView: View {
201 242
             minimumYSpan: minimumPowerSpan,
202 243
             visibleTimeRange: visibleTimeRange
203 244
         )
245
+        let energySeries = series(
246
+            for: measurements.energy,
247
+            kind: .energy,
248
+            minimumYSpan: minimumEnergySpan,
249
+            visibleTimeRange: visibleTimeRange
250
+        )
204 251
         let voltageSeries = series(
205 252
             for: measurements.voltage,
206 253
             kind: .voltage,
@@ -221,6 +268,7 @@ struct MeasurementChartView: View {
221 268
         )
222 269
         let primarySeries = displayedPrimarySeries(
223 270
             powerSeries: powerSeries,
271
+            energySeries: energySeries,
224 272
             voltageSeries: voltageSeries,
225 273
             currentSeries: currentSeries
226 274
         )
@@ -239,6 +287,7 @@ struct MeasurementChartView: View {
239 287
                                 primaryAxisView(
240 288
                                     height: plotHeight,
241 289
                                     powerSeries: powerSeries,
290
+                                    energySeries: energySeries,
242 291
                                     voltageSeries: voltageSeries,
243 292
                                     currentSeries: currentSeries
244 293
                                 )
@@ -256,6 +305,7 @@ struct MeasurementChartView: View {
256 305
                                     discontinuityMarkers(points: primarySeries.points, context: primarySeries.context)
257 306
                                     renderedChart(
258 307
                                         powerSeries: powerSeries,
308
+                                        energySeries: energySeries,
259 309
                                         voltageSeries: voltageSeries,
260 310
                                         currentSeries: currentSeries,
261 311
                                         temperatureSeries: temperatureSeries
@@ -268,6 +318,7 @@ struct MeasurementChartView: View {
268 318
                                 secondaryAxisView(
269 319
                                     height: plotHeight,
270 320
                                     powerSeries: powerSeries,
321
+                                    energySeries: energySeries,
271 322
                                     voltageSeries: voltageSeries,
272 323
                                     currentSeries: currentSeries,
273 324
                                     temperatureSeries: temperatureSeries
@@ -322,9 +373,12 @@ struct MeasurementChartView: View {
322 373
                                     points: selectorSeries.points,
323 374
                                     context: selectorSeries.context,
324 375
                                     availableTimeRange: availableTimeRange,
325
-                                    accentColor: selectorSeries.kind.tint,
376
+                                    selectorTint: selectorTint,
326 377
                                     compactLayout: compactLayout,
327 378
                                     minimumSelectionSpan: minimumTimeSpan,
379
+                                    onKeepSelection: trimBufferToSelection,
380
+                                    onRemoveSelection: removeSelectionFromBuffer,
381
+                                    onResetBuffer: resetBuffer,
328 382
                                     selectedTimeRange: $selectedVisibleTimeRange,
329 383
                                     isPinnedToPresent: $isPinnedToPresent,
330 384
                                     presentTrackingMode: $presentTrackingMode
@@ -355,8 +409,9 @@ struct MeasurementChartView: View {
355 409
         let condensedLayout = compactLayout || verticalSizeClass == .compact
356 410
         let sectionSpacing: CGFloat = condensedLayout ? 8 : (isLargeDisplay ? 12 : 10)
357 411
 
358
-        let controlsPanel = HStack(alignment: .center, spacing: sectionSpacing) {
412
+        let controlsPanel = VStack(alignment: .leading, spacing: sectionSpacing) {
359 413
             seriesToggleRow(condensedLayout: condensedLayout)
414
+            smoothingControlsRow(condensedLayout: condensedLayout)
360 415
         }
361 416
         .padding(.horizontal, condensedLayout ? 10 : (isLargeDisplay ? 14 : 12))
362 417
         .padding(.vertical, condensedLayout ? 8 : (isLargeDisplay ? 12 : 10))
@@ -371,18 +426,10 @@ struct MeasurementChartView: View {
371 426
 
372 427
         return Group {
373 428
             if stackedToolbarLayout {
374
-                VStack(alignment: .leading, spacing: condensedLayout ? 8 : 10) {
375
-                    controlsPanel
376
-                    HStack {
377
-                        Spacer(minLength: 0)
378
-                        resetBufferButton(condensedLayout: condensedLayout)
379
-                    }
380
-                }
429
+                controlsPanel
381 430
             } else {
382 431
                 HStack(alignment: .top, spacing: isLargeDisplay ? 14 : 12) {
383 432
                     controlsPanel
384
-                    Spacer(minLength: 0)
385
-                    resetBufferButton(condensedLayout: condensedLayout)
386 433
                 }
387 434
             }
388 435
         }
@@ -436,6 +483,7 @@ struct MeasurementChartView: View {
436 483
                 displayVoltage.toggle()
437 484
                 if displayVoltage {
438 485
                     displayPower = false
486
+                    displayEnergy = false
439 487
                     if displayTemperature && displayCurrent {
440 488
                         displayCurrent = false
441 489
                     }
@@ -446,6 +494,7 @@ struct MeasurementChartView: View {
446 494
                 displayCurrent.toggle()
447 495
                 if displayCurrent {
448 496
                     displayPower = false
497
+                    displayEnergy = false
449 498
                     if displayTemperature && displayVoltage {
450 499
                         displayVoltage = false
451 500
                     }
@@ -455,6 +504,16 @@ struct MeasurementChartView: View {
455 504
             seriesToggleButton(title: "Power", isOn: displayPower, condensedLayout: condensedLayout) {
456 505
                 displayPower.toggle()
457 506
                 if displayPower {
507
+                    displayEnergy = false
508
+                    displayCurrent = false
509
+                    displayVoltage = false
510
+                }
511
+            }
512
+
513
+            seriesToggleButton(title: "Energy", isOn: displayEnergy, condensedLayout: condensedLayout) {
514
+                displayEnergy.toggle()
515
+                if displayEnergy {
516
+                    displayPower = false
458 517
                     displayCurrent = false
459 518
                     displayVoltage = false
460 519
                 }
@@ -525,6 +584,64 @@ struct MeasurementChartView: View {
525 584
         }
526 585
     }
527 586
 
587
+    private func smoothingControlsRow(condensedLayout: Bool) -> some View {
588
+        HStack(spacing: condensedLayout ? 8 : 10) {
589
+            Text("Smoothing")
590
+                .font((condensedLayout ? Font.caption : .footnote).weight(.semibold))
591
+                .foregroundColor(.secondary)
592
+
593
+            Menu {
594
+                ForEach(SmoothingLevel.allCases, id: \.self) { level in
595
+                    Button {
596
+                        smoothingLevel = level
597
+                    } label: {
598
+                        if smoothingLevel == level {
599
+                            Label(level.label, systemImage: "checkmark")
600
+                        } else {
601
+                            Text(level.label)
602
+                        }
603
+                    }
604
+                }
605
+            } label: {
606
+                Label(
607
+                    condensedLayout ? smoothingLevel.shortLabel : smoothingLevel.label,
608
+                    systemImage: "waveform.path"
609
+                )
610
+                .font(controlChipFont(condensedLayout: condensedLayout))
611
+                .foregroundColor(smoothingLevel == .off ? .primary : .blue)
612
+                .padding(.horizontal, condensedLayout ? 10 : 12)
613
+                .padding(.vertical, condensedLayout ? 7 : (isLargeDisplay ? 9 : 8))
614
+                .background(
615
+                    Capsule(style: .continuous)
616
+                        .fill(
617
+                            smoothingLevel == .off
618
+                            ? Color.secondary.opacity(0.10)
619
+                            : Color.blue.opacity(0.12)
620
+                        )
621
+                )
622
+                .overlay(
623
+                    Capsule(style: .continuous)
624
+                        .stroke(
625
+                            smoothingLevel == .off
626
+                            ? Color.secondary.opacity(0.18)
627
+                            : Color.blue.opacity(0.28),
628
+                            lineWidth: 1
629
+                        )
630
+                )
631
+            }
632
+            .fixedSize(horizontal: true, vertical: false)
633
+
634
+            if smoothingLevel != .off {
635
+                Text("MA \(smoothingLevel.movingAverageWindowSize)")
636
+                    .font((condensedLayout ? Font.caption2 : .caption).weight(.semibold))
637
+                    .foregroundColor(.secondary)
638
+                    .monospacedDigit()
639
+            }
640
+
641
+            Spacer(minLength: 0)
642
+        }
643
+    }
644
+
528 645
     private func seriesToggleButton(
529 646
         title: String,
530 647
         isOn: Bool,
@@ -592,28 +709,8 @@ struct MeasurementChartView: View {
592 709
         .accessibilityLabel(accessibilityLabel)
593 710
     }
594 711
 
595
-    private func resetBufferButton(condensedLayout: Bool) -> some View {
596
-        Button(action: {
597
-            showResetConfirmation = true
598
-        }) {
599
-            Label(condensedLayout ? "Reset" : "Reset Buffer", systemImage: "trash")
600
-                .font(controlChipFont(condensedLayout: condensedLayout))
601
-                .padding(.horizontal, condensedLayout ? 14 : 16)
602
-                .padding(.vertical, condensedLayout ? 10 : (isLargeDisplay ? 12 : 11))
603
-        }
604
-        .buttonStyle(.plain)
605
-        .foregroundColor(.white)
606
-        .background(
607
-            Capsule(style: .continuous)
608
-                .fill(Color.red.opacity(0.8))
609
-        )
610
-        .fixedSize(horizontal: true, vertical: false)
611
-        .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
612
-            Button("Reset series", role: .destructive) {
613
-                measurements.resetSeries()
614
-            }
615
-            Button("Cancel", role: .cancel) {}
616
-        }
712
+    private func resetBuffer() {
713
+        measurements.resetSeries()
617 714
     }
618 715
 
619 716
     private func seriesToggleFont(condensedLayout: Bool) -> Font {
@@ -634,6 +731,7 @@ struct MeasurementChartView: View {
634 731
     private func primaryAxisView(
635 732
         height: CGFloat,
636 733
         powerSeries: SeriesData,
734
+        energySeries: SeriesData,
637 735
         voltageSeries: SeriesData,
638 736
         currentSeries: SeriesData
639 737
     ) -> some View {
@@ -645,6 +743,14 @@ struct MeasurementChartView: View {
645 743
                 measurementUnit: powerSeries.kind.unit,
646 744
                 tint: powerSeries.kind.tint
647 745
             )
746
+        } else if displayEnergy {
747
+            yAxisLabelsView(
748
+                height: height,
749
+                context: energySeries.context,
750
+                seriesKind: .energy,
751
+                measurementUnit: energySeries.kind.unit,
752
+                tint: energySeries.kind.tint
753
+            )
648 754
         } else if displayVoltage {
649 755
             yAxisLabelsView(
650 756
                 height: height,
@@ -667,6 +773,7 @@ struct MeasurementChartView: View {
667 773
     @ViewBuilder
668 774
     private func renderedChart(
669 775
         powerSeries: SeriesData,
776
+        energySeries: SeriesData,
670 777
         voltageSeries: SeriesData,
671 778
         currentSeries: SeriesData,
672 779
         temperatureSeries: SeriesData
@@ -674,6 +781,9 @@ struct MeasurementChartView: View {
674 781
         if self.displayPower {
675 782
             Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
676 783
                 .opacity(0.72)
784
+        } else if self.displayEnergy {
785
+            Chart(points: energySeries.points, context: energySeries.context, strokeColor: .teal)
786
+                .opacity(0.78)
677 787
         } else {
678 788
             if self.displayVoltage {
679 789
                 Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
@@ -695,6 +805,7 @@ struct MeasurementChartView: View {
695 805
     private func secondaryAxisView(
696 806
         height: CGFloat,
697 807
         powerSeries: SeriesData,
808
+        energySeries: SeriesData,
698 809
         voltageSeries: SeriesData,
699 810
         currentSeries: SeriesData,
700 811
         temperatureSeries: SeriesData
@@ -719,6 +830,7 @@ struct MeasurementChartView: View {
719 830
             primaryAxisView(
720 831
                 height: height,
721 832
                 powerSeries: powerSeries,
833
+                energySeries: energySeries,
722 834
                 voltageSeries: voltageSeries,
723 835
                 currentSeries: currentSeries
724 836
             )
@@ -727,12 +839,16 @@ struct MeasurementChartView: View {
727 839
 
728 840
     private func displayedPrimarySeries(
729 841
         powerSeries: SeriesData,
842
+        energySeries: SeriesData,
730 843
         voltageSeries: SeriesData,
731 844
         currentSeries: SeriesData
732 845
     ) -> SeriesData? {
733 846
         if displayPower {
734 847
             return powerSeries
735 848
         }
849
+        if displayEnergy {
850
+            return energySeries
851
+        }
736 852
         if displayVoltage {
737 853
             return voltageSeries
738 854
         }
@@ -748,10 +864,11 @@ struct MeasurementChartView: View {
748 864
         minimumYSpan: Double,
749 865
         visibleTimeRange: ClosedRange<Date>? = nil
750 866
     ) -> SeriesData {
751
-        let points = filteredPoints(
867
+        let rawPoints = filteredPoints(
752 868
             measurement,
753 869
             visibleTimeRange: visibleTimeRange
754 870
         )
871
+        let points = smoothedPoints(from: rawPoints)
755 872
         let samplePoints = points.filter { $0.isSample }
756 873
         let context = ChartContext()
757 874
 
@@ -801,10 +918,89 @@ struct MeasurementChartView: View {
801 918
         )
802 919
     }
803 920
 
921
+    private func smoothedPoints(
922
+        from points: [Measurements.Measurement.Point]
923
+    ) -> [Measurements.Measurement.Point] {
924
+        guard smoothingLevel != .off else { return points }
925
+
926
+        var smoothedPoints: [Measurements.Measurement.Point] = []
927
+        var currentSegment: [Measurements.Measurement.Point] = []
928
+
929
+        func flushCurrentSegment() {
930
+            guard !currentSegment.isEmpty else { return }
931
+
932
+            for point in smoothedSegment(currentSegment) {
933
+                smoothedPoints.append(
934
+                    Measurements.Measurement.Point(
935
+                        id: smoothedPoints.count,
936
+                        timestamp: point.timestamp,
937
+                        value: point.value,
938
+                        kind: .sample
939
+                    )
940
+                )
941
+            }
942
+
943
+            currentSegment.removeAll(keepingCapacity: true)
944
+        }
945
+
946
+        for point in points {
947
+            if point.isDiscontinuity {
948
+                flushCurrentSegment()
949
+
950
+                if !smoothedPoints.isEmpty, smoothedPoints.last?.isDiscontinuity == false {
951
+                    smoothedPoints.append(
952
+                        Measurements.Measurement.Point(
953
+                            id: smoothedPoints.count,
954
+                            timestamp: point.timestamp,
955
+                            value: smoothedPoints.last?.value ?? point.value,
956
+                            kind: .discontinuity
957
+                        )
958
+                    )
959
+                }
960
+            } else {
961
+                currentSegment.append(point)
962
+            }
963
+        }
964
+
965
+        flushCurrentSegment()
966
+        return smoothedPoints
967
+    }
968
+
969
+    private func smoothedSegment(
970
+        _ segment: [Measurements.Measurement.Point]
971
+    ) -> [Measurements.Measurement.Point] {
972
+        let windowSize = smoothingLevel.movingAverageWindowSize
973
+        guard windowSize > 1, segment.count > 2 else { return segment }
974
+
975
+        let radius = windowSize / 2
976
+        var prefixSums: [Double] = [0]
977
+        prefixSums.reserveCapacity(segment.count + 1)
978
+
979
+        for point in segment {
980
+            prefixSums.append(prefixSums[prefixSums.count - 1] + point.value)
981
+        }
982
+
983
+        return segment.enumerated().map { index, point in
984
+            let lowerBound = max(0, index - radius)
985
+            let upperBound = min(segment.count - 1, index + radius)
986
+            let sum = prefixSums[upperBound + 1] - prefixSums[lowerBound]
987
+            let average = sum / Double(upperBound - lowerBound + 1)
988
+
989
+            return Measurements.Measurement.Point(
990
+                id: point.id,
991
+                timestamp: point.timestamp,
992
+                value: average,
993
+                kind: .sample
994
+            )
995
+        }
996
+    }
997
+
804 998
     private func measurement(for kind: SeriesKind) -> Measurements.Measurement {
805 999
         switch kind {
806 1000
         case .power:
807 1001
             return measurements.power
1002
+        case .energy:
1003
+            return measurements.energy
808 1004
         case .voltage:
809 1005
             return measurements.voltage
810 1006
         case .current:
@@ -818,6 +1014,8 @@ struct MeasurementChartView: View {
818 1014
         switch kind {
819 1015
         case .power:
820 1016
             return minimumPowerSpan
1017
+        case .energy:
1018
+            return minimumEnergySpan
821 1019
         case .voltage:
822 1020
             return minimumVoltageSpan
823 1021
         case .current:
@@ -828,7 +1026,7 @@ struct MeasurementChartView: View {
828 1026
     }
829 1027
 
830 1028
     private var supportsSharedOrigin: Bool {
831
-        displayVoltage && displayCurrent && !displayPower
1029
+        displayVoltage && displayCurrent && !displayPower && !displayEnergy
832 1030
     }
833 1031
 
834 1032
     private var minimumSharedScaleSpan: Double {
@@ -844,6 +1042,10 @@ struct MeasurementChartView: View {
844 1042
             return pinOrigin && powerAxisOrigin == 0
845 1043
         }
846 1044
 
1045
+        if displayEnergy {
1046
+            return pinOrigin && energyAxisOrigin == 0
1047
+        }
1048
+
847 1049
         let visibleOrigins = [
848 1050
             displayVoltage ? voltageAxisOrigin : nil,
849 1051
             displayCurrent ? currentAxisOrigin : nil
@@ -904,6 +1106,9 @@ struct MeasurementChartView: View {
904 1106
             if displayPower {
905 1107
                 powerAxisOrigin = 0
906 1108
             }
1109
+            if displayEnergy {
1110
+                energyAxisOrigin = 0
1111
+            }
907 1112
             if displayVoltage {
908 1113
                 voltageAxisOrigin = 0
909 1114
             }
@@ -923,6 +1128,7 @@ struct MeasurementChartView: View {
923 1128
         currentSeries: SeriesData
924 1129
     ) {
925 1130
         powerAxisOrigin = displayedLowerBoundForSeries(.power)
1131
+        energyAxisOrigin = displayedLowerBoundForSeries(.energy)
926 1132
         voltageAxisOrigin = voltageSeries.autoLowerBound
927 1133
         currentAxisOrigin = currentSeries.autoLowerBound
928 1134
         temperatureAxisOrigin = displayedLowerBoundForSeries(.temperature)
@@ -945,6 +1151,16 @@ struct MeasurementChartView: View {
945 1151
                     ),
946 1152
                     minimumYSpan: minimumPowerSpan
947 1153
                 ).lowerBound
1154
+        case .energy:
1155
+            return pinOrigin
1156
+                ? energyAxisOrigin
1157
+                : automaticYBounds(
1158
+                    for: filteredSamplePoints(
1159
+                        measurements.energy,
1160
+                        visibleTimeRange: visibleTimeRange
1161
+                    ),
1162
+                    minimumYSpan: minimumEnergySpan
1163
+                ).lowerBound
948 1164
         case .voltage:
949 1165
             if pinOrigin && useSharedOrigin && supportsSharedOrigin {
950 1166
                 return sharedAxisOrigin
@@ -992,10 +1208,26 @@ struct MeasurementChartView: View {
992 1208
         _ measurement: Measurements.Measurement,
993 1209
         visibleTimeRange: ClosedRange<Date>? = nil
994 1210
     ) -> [Measurements.Measurement.Point] {
995
-        measurement.points.filter { point in
996
-            guard timeRange?.contains(point.timestamp) ?? true else { return false }
997
-            return visibleTimeRange?.contains(point.timestamp) ?? true
1211
+        let resolvedRange: ClosedRange<Date>?
1212
+
1213
+        switch (timeRange, visibleTimeRange) {
1214
+        case let (baseRange?, visibleRange?):
1215
+            let lowerBound = max(baseRange.lowerBound, visibleRange.lowerBound)
1216
+            let upperBound = min(baseRange.upperBound, visibleRange.upperBound)
1217
+            resolvedRange = lowerBound <= upperBound ? lowerBound...upperBound : nil
1218
+        case let (baseRange?, nil):
1219
+            resolvedRange = baseRange
1220
+        case let (nil, visibleRange?):
1221
+            resolvedRange = visibleRange
1222
+        case (nil, nil):
1223
+            resolvedRange = nil
998 1224
         }
1225
+
1226
+        guard let resolvedRange else {
1227
+            return timeRange == nil && visibleTimeRange == nil ? measurement.points : []
1228
+        }
1229
+
1230
+        return measurement.points(in: resolvedRange)
999 1231
     }
1000 1232
 
1001 1233
     private func filteredSamplePoints(
@@ -1042,6 +1274,7 @@ struct MeasurementChartView: View {
1042 1274
     private func timelineSamplePoints() -> [Measurements.Measurement.Point] {
1043 1275
         let candidates = [
1044 1276
             filteredSamplePoints(measurements.power),
1277
+            filteredSamplePoints(measurements.energy),
1045 1278
             filteredSamplePoints(measurements.voltage),
1046 1279
             filteredSamplePoints(measurements.current),
1047 1280
             filteredSamplePoints(measurements.temperature)
@@ -1183,6 +1416,8 @@ struct MeasurementChartView: View {
1183 1416
         switch kind {
1184 1417
         case .power:
1185 1418
             return powerAxisOrigin
1419
+        case .energy:
1420
+            return energyAxisOrigin
1186 1421
         case .voltage:
1187 1422
             return voltageAxisOrigin
1188 1423
         case .current:
@@ -1231,6 +1466,8 @@ struct MeasurementChartView: View {
1231 1466
             switch kind {
1232 1467
             case .power:
1233 1468
                 powerAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .power))
1469
+            case .energy:
1470
+                energyAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .energy))
1234 1471
             case .voltage:
1235 1472
                 voltageAxisOrigin = min(proposedOrigin, maximumVisibleOrigin(for: .voltage))
1236 1473
             case .current:
@@ -1255,6 +1492,8 @@ struct MeasurementChartView: View {
1255 1492
             switch kind {
1256 1493
             case .power:
1257 1494
                 powerAxisOrigin = 0
1495
+            case .energy:
1496
+                energyAxisOrigin = 0
1258 1497
             case .voltage:
1259 1498
                 voltageAxisOrigin = 0
1260 1499
             case .current:
@@ -1291,6 +1530,13 @@ struct MeasurementChartView: View {
1291 1530
                     visibleTimeRange: visibleTimeRange
1292 1531
                 ).map(\.value).min() ?? 0
1293 1532
             )
1533
+        case .energy:
1534
+            return snappedOriginValue(
1535
+                filteredSamplePoints(
1536
+                    measurements.energy,
1537
+                    visibleTimeRange: visibleTimeRange
1538
+                ).map(\.value).min() ?? 0
1539
+            )
1294 1540
         case .voltage:
1295 1541
             return snappedOriginValue(
1296 1542
                 filteredSamplePoints(
@@ -1355,6 +1601,18 @@ struct MeasurementChartView: View {
1355 1601
         return value.rounded(.up)
1356 1602
     }
1357 1603
 
1604
+    private func trimBufferToSelection(_ range: ClosedRange<Date>) {
1605
+        measurements.keepOnly(in: range)
1606
+        selectedVisibleTimeRange = nil
1607
+        isPinnedToPresent = false
1608
+    }
1609
+
1610
+    private func removeSelectionFromBuffer(_ range: ClosedRange<Date>) {
1611
+        measurements.removeValues(in: range)
1612
+        selectedVisibleTimeRange = nil
1613
+        isPinnedToPresent = false
1614
+    }
1615
+
1358 1616
     private func yGuidePosition(
1359 1617
         for labelIndex: Int,
1360 1618
         context: ChartContext,
@@ -1588,6 +1846,12 @@ private struct TimeRangeSelectorView: View {
1588 1846
         case window
1589 1847
     }
1590 1848
 
1849
+    private enum ActionTone {
1850
+        case reversible
1851
+        case destructive
1852
+        case destructiveProminent
1853
+    }
1854
+
1591 1855
     private struct DragState {
1592 1856
         let target: DragTarget
1593 1857
         let initialRange: ClosedRange<Date>
@@ -1596,14 +1860,18 @@ private struct TimeRangeSelectorView: View {
1596 1860
     let points: [Measurements.Measurement.Point]
1597 1861
     let context: ChartContext
1598 1862
     let availableTimeRange: ClosedRange<Date>
1599
-    let accentColor: Color
1863
+    let selectorTint: Color
1600 1864
     let compactLayout: Bool
1601 1865
     let minimumSelectionSpan: TimeInterval
1866
+    let onKeepSelection: (ClosedRange<Date>) -> Void
1867
+    let onRemoveSelection: (ClosedRange<Date>) -> Void
1868
+    let onResetBuffer: () -> Void
1602 1869
 
1603 1870
     @Binding var selectedTimeRange: ClosedRange<Date>?
1604 1871
     @Binding var isPinnedToPresent: Bool
1605 1872
     @Binding var presentTrackingMode: PresentTrackingMode
1606 1873
     @State private var dragState: DragState?
1874
+    @State private var showResetConfirmation: Bool = false
1607 1875
 
1608 1876
     private var totalSpan: TimeInterval {
1609 1877
         availableTimeRange.upperBound.timeIntervalSince(availableTimeRange.lowerBound)
@@ -1621,10 +1889,6 @@ private struct TimeRangeSelectorView: View {
1621 1889
         compactLayout ? 14 : 16
1622 1890
     }
1623 1891
 
1624
-    private var summaryFont: Font {
1625
-        compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1626
-    }
1627
-
1628 1892
     private var boundaryFont: Font {
1629 1893
         compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold)
1630 1894
     }
@@ -1661,6 +1925,45 @@ private struct TimeRangeSelectorView: View {
1661 1925
                 }
1662 1926
             }
1663 1927
 
1928
+            HStack(spacing: 8) {
1929
+                if !coversFullRange {
1930
+                    actionButton(
1931
+                        title: compactLayout ? "Keep" : "Keep Selection",
1932
+                        systemName: "scissors",
1933
+                        tone: .destructive,
1934
+                        action: {
1935
+                            onKeepSelection(currentRange)
1936
+                        }
1937
+                    )
1938
+
1939
+                    actionButton(
1940
+                        title: compactLayout ? "Cut" : "Remove Selection",
1941
+                        systemName: "minus.circle",
1942
+                        tone: .destructive,
1943
+                        action: {
1944
+                            onRemoveSelection(currentRange)
1945
+                        }
1946
+                    )
1947
+                }
1948
+
1949
+                Spacer(minLength: 0)
1950
+
1951
+                actionButton(
1952
+                    title: compactLayout ? "Reset" : "Reset Buffer",
1953
+                    systemName: "trash",
1954
+                    tone: .destructiveProminent,
1955
+                    action: {
1956
+                        showResetConfirmation = true
1957
+                    }
1958
+                )
1959
+            }
1960
+            .confirmationDialog("Reset captured measurements?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
1961
+                Button("Reset buffer", role: .destructive) {
1962
+                    onResetBuffer()
1963
+                }
1964
+                Button("Cancel", role: .cancel) {}
1965
+            }
1966
+
1664 1967
             GeometryReader { geometry in
1665 1968
                 let selectionFrame = selectionFrame(in: geometry.size)
1666 1969
                 let dimmingColor = Color(uiColor: .systemBackground).opacity(0.58)
@@ -1673,8 +1976,8 @@ private struct TimeRangeSelectorView: View {
1673 1976
                         points: points,
1674 1977
                         context: context,
1675 1978
                         areaChart: true,
1676
-                        strokeColor: accentColor,
1677
-                        areaFillColor: accentColor.opacity(0.22)
1979
+                        strokeColor: selectorTint,
1980
+                        areaFillColor: selectorTint.opacity(0.22)
1678 1981
                     )
1679 1982
                     .opacity(0.94)
1680 1983
                     .allowsHitTesting(false)
@@ -1682,7 +1985,7 @@ private struct TimeRangeSelectorView: View {
1682 1985
                     Chart(
1683 1986
                         points: points,
1684 1987
                         context: context,
1685
-                        strokeColor: accentColor.opacity(0.56)
1988
+                        strokeColor: selectorTint.opacity(0.56)
1686 1989
                     )
1687 1990
                     .opacity(0.82)
1688 1991
                     .allowsHitTesting(false)
@@ -1706,13 +2009,13 @@ private struct TimeRangeSelectorView: View {
1706 2009
                     }
1707 2010
 
1708 2011
                     RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
1709
-                        .fill(accentColor.opacity(0.18))
2012
+                        .fill(selectorTint.opacity(0.18))
1710 2013
                         .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
1711 2014
                         .offset(x: selectionFrame.minX)
1712 2015
                         .allowsHitTesting(false)
1713 2016
 
1714 2017
                     RoundedRectangle(cornerRadius: max(cornerRadius - 2, 10), style: .continuous)
1715
-                        .stroke(accentColor.opacity(0.52), lineWidth: 1.2)
2018
+                        .stroke(selectorTint.opacity(0.52), lineWidth: 1.2)
1716 2019
                         .frame(width: max(selectionFrame.width, 2), height: geometry.size.height)
1717 2020
                         .offset(x: selectionFrame.minX)
1718 2021
                         .allowsHitTesting(false)
@@ -1765,14 +2068,14 @@ private struct TimeRangeSelectorView: View {
1765 2068
                 .frame(width: symbolButtonSize, height: symbolButtonSize)
1766 2069
         }
1767 2070
         .buttonStyle(.plain)
1768
-        .foregroundColor(isActive ? .white : accentColor)
2071
+        .foregroundColor(isActive ? .white : selectorTint)
1769 2072
         .background(
1770 2073
             RoundedRectangle(cornerRadius: 9, style: .continuous)
1771
-                .fill(isActive ? accentColor : accentColor.opacity(0.14))
2074
+                .fill(isActive ? selectorTint : selectorTint.opacity(0.14))
1772 2075
         )
1773 2076
         .overlay(
1774 2077
             RoundedRectangle(cornerRadius: 9, style: .continuous)
1775
-                .stroke(accentColor.opacity(0.28), lineWidth: 1)
2078
+                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
1776 2079
         )
1777 2080
         .accessibilityLabel(accessibilityLabel)
1778 2081
     }
@@ -1791,16 +2094,80 @@ private struct TimeRangeSelectorView: View {
1791 2094
         .foregroundColor(.white)
1792 2095
         .background(
1793 2096
             RoundedRectangle(cornerRadius: 9, style: .continuous)
1794
-                .fill(accentColor)
2097
+                .fill(selectorTint)
1795 2098
         )
1796 2099
         .overlay(
1797 2100
             RoundedRectangle(cornerRadius: 9, style: .continuous)
1798
-                .stroke(accentColor.opacity(0.28), lineWidth: 1)
2101
+                .stroke(selectorTint.opacity(0.28), lineWidth: 1)
1799 2102
         )
1800 2103
         .accessibilityLabel(trackingModeAccessibilityLabel)
1801 2104
         .accessibilityHint("Toggles how the interval follows the present")
1802 2105
     }
1803 2106
 
2107
+    private func actionButton(
2108
+        title: String,
2109
+        systemName: String,
2110
+        tone: ActionTone,
2111
+        action: @escaping () -> Void
2112
+    ) -> some View {
2113
+        let foregroundColor: Color = {
2114
+            switch tone {
2115
+            case .reversible, .destructive:
2116
+                return toneColor(for: tone)
2117
+            case .destructiveProminent:
2118
+                return .white
2119
+            }
2120
+        }()
2121
+
2122
+        return Button(action: action) {
2123
+            Label(title, systemImage: systemName)
2124
+                .font(compactLayout ? .caption.weight(.semibold) : .footnote.weight(.semibold))
2125
+                .padding(.horizontal, compactLayout ? 10 : 12)
2126
+                .padding(.vertical, compactLayout ? 7 : 8)
2127
+        }
2128
+        .buttonStyle(.plain)
2129
+        .foregroundColor(foregroundColor)
2130
+        .background(
2131
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
2132
+                .fill(actionButtonBackground(for: tone))
2133
+        )
2134
+        .overlay(
2135
+            RoundedRectangle(cornerRadius: 10, style: .continuous)
2136
+                .stroke(actionButtonBorder(for: tone), lineWidth: 1)
2137
+        )
2138
+    }
2139
+
2140
+    private func toneColor(for tone: ActionTone) -> Color {
2141
+        switch tone {
2142
+        case .reversible:
2143
+            return selectorTint
2144
+        case .destructive, .destructiveProminent:
2145
+            return .red
2146
+        }
2147
+    }
2148
+
2149
+    private func actionButtonBackground(for tone: ActionTone) -> Color {
2150
+        switch tone {
2151
+        case .reversible:
2152
+            return selectorTint.opacity(0.12)
2153
+        case .destructive:
2154
+            return Color.red.opacity(0.12)
2155
+        case .destructiveProminent:
2156
+            return Color.red.opacity(0.82)
2157
+        }
2158
+    }
2159
+
2160
+    private func actionButtonBorder(for tone: ActionTone) -> Color {
2161
+        switch tone {
2162
+        case .reversible:
2163
+            return selectorTint.opacity(0.22)
2164
+        case .destructive:
2165
+            return Color.red.opacity(0.22)
2166
+        case .destructiveProminent:
2167
+            return Color.red.opacity(0.72)
2168
+        }
2169
+    }
2170
+
1804 2171
     private var trackingModeSymbolName: String {
1805 2172
         switch presentTrackingMode {
1806 2173
         case .keepDuration:
@@ -2154,12 +2521,6 @@ private struct TimeRangeSelectorView: View {
2154 2521
         date.format(as: boundaryDateFormat)
2155 2522
     }
2156 2523
 
2157
-    private func selectionSummary(
2158
-        for range: ClosedRange<Date>
2159
-    ) -> String {
2160
-        "\(range.lowerBound.format(as: summaryDateFormat)) - \(range.upperBound.format(as: summaryDateFormat))"
2161
-    }
2162
-
2163 2524
     private var boundaryDateFormat: String {
2164 2525
         switch totalSpan {
2165 2526
         case 0..<86400:
@@ -2170,21 +2531,12 @@ private struct TimeRangeSelectorView: View {
2170 2531
             return "MMM d"
2171 2532
         }
2172 2533
     }
2173
-
2174
-    private var summaryDateFormat: String {
2175
-        switch totalSpan {
2176
-        case 0..<3600:
2177
-            return "HH:mm:ss"
2178
-        case 3600..<172800:
2179
-            return "MMM d HH:mm"
2180
-        default:
2181
-            return "MMM d"
2182
-        }
2183
-    }
2184 2534
 }
2185 2535
 
2186 2536
 struct Chart : View {
2187 2537
     
2538
+    @Environment(\.displayScale) private var displayScale
2539
+
2188 2540
     let points: [Measurements.Measurement.Point]
2189 2541
     let context: ChartContext
2190 2542
     var areaChart: Bool = false
@@ -2216,13 +2568,26 @@ struct Chart : View {
2216 2568
     }
2217 2569
     
2218 2570
     fileprivate func path(geometry: GeometryProxy) -> Path {
2571
+        let displayedPoints = scaledPoints(for: geometry.size.width)
2572
+        let baselineY = context.placeInRect(
2573
+            point: CGPoint(x: context.origin.x, y: context.origin.y)
2574
+        ).y * geometry.size.height
2575
+
2219 2576
         return Path { path in
2220
-            var firstSample: Measurements.Measurement.Point?
2221
-            var lastSample: Measurements.Measurement.Point?
2577
+            var firstRenderedPoint: CGPoint?
2578
+            var lastRenderedPoint: CGPoint?
2222 2579
             var needsMove = true
2223 2580
 
2224
-            for point in points {
2581
+            for point in displayedPoints {
2225 2582
                 if point.isDiscontinuity {
2583
+                    closeAreaSegment(
2584
+                        in: &path,
2585
+                        firstPoint: firstRenderedPoint,
2586
+                        lastPoint: lastRenderedPoint,
2587
+                        baselineY: baselineY
2588
+                    )
2589
+                    firstRenderedPoint = nil
2590
+                    lastRenderedPoint = nil
2226 2591
                     needsMove = true
2227 2592
                     continue
2228 2593
                 }
@@ -2233,28 +2598,172 @@ struct Chart : View {
2233 2598
                     y: item.y * geometry.size.height
2234 2599
                 )
2235 2600
 
2236
-                if firstSample == nil {
2237
-                    firstSample = point
2238
-                }
2239
-                lastSample = point
2240
-
2241 2601
                 if needsMove {
2242 2602
                     path.move(to: renderedPoint)
2603
+                    firstRenderedPoint = renderedPoint
2243 2604
                     needsMove = false
2244 2605
                 } else {
2245 2606
                     path.addLine(to: renderedPoint)
2246 2607
                 }
2608
+
2609
+                lastRenderedPoint = renderedPoint
2247 2610
             }
2248 2611
 
2249
-            if self.areaChart, let firstSample, let lastSample {
2250
-                let lastPointX = context.placeInRect(point: CGPoint(x: lastSample.point().x, y: context.origin.y ))
2251
-                let firstPointX = context.placeInRect(point: CGPoint(x: firstSample.point().x, y: context.origin.y ))
2252
-                path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
2253
-                path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
2254
-                // MARK: Nu e nevoie. Fill inchide automat calea
2255
-                // path.closeSubpath()
2612
+            closeAreaSegment(
2613
+                in: &path,
2614
+                firstPoint: firstRenderedPoint,
2615
+                lastPoint: lastRenderedPoint,
2616
+                baselineY: baselineY
2617
+            )
2618
+        }
2619
+    }
2620
+
2621
+    private func closeAreaSegment(
2622
+        in path: inout Path,
2623
+        firstPoint: CGPoint?,
2624
+        lastPoint: CGPoint?,
2625
+        baselineY: CGFloat
2626
+    ) {
2627
+        guard areaChart, let firstPoint, let lastPoint, firstPoint != lastPoint else { return }
2628
+
2629
+        path.addLine(to: CGPoint(x: lastPoint.x, y: baselineY))
2630
+        path.addLine(to: CGPoint(x: firstPoint.x, y: baselineY))
2631
+        path.closeSubpath()
2632
+    }
2633
+
2634
+    private func scaledPoints(for width: CGFloat) -> [Measurements.Measurement.Point] {
2635
+        let sampleCount = points.reduce(into: 0) { partialResult, point in
2636
+            if point.isSample {
2637
+                partialResult += 1
2638
+            }
2639
+        }
2640
+
2641
+        let displayColumns = max(Int((width * max(displayScale, 1)).rounded(.up)), 1)
2642
+        let maximumSamplesToRender = max(displayColumns * (areaChart ? 3 : 4), 240)
2643
+
2644
+        guard sampleCount > maximumSamplesToRender, context.isValid else {
2645
+            return points
2646
+        }
2647
+
2648
+        var scaledPoints: [Measurements.Measurement.Point] = []
2649
+        var currentSegment: [Measurements.Measurement.Point] = []
2650
+
2651
+        for point in points {
2652
+            if point.isDiscontinuity {
2653
+                appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2654
+                currentSegment.removeAll(keepingCapacity: true)
2655
+
2656
+                if !scaledPoints.isEmpty, scaledPoints.last?.isDiscontinuity == false {
2657
+                    appendScaledPoint(point, to: &scaledPoints)
2658
+                }
2659
+            } else {
2660
+                currentSegment.append(point)
2661
+            }
2662
+        }
2663
+
2664
+        appendScaledSegment(currentSegment, to: &scaledPoints, displayColumns: displayColumns)
2665
+        return scaledPoints.isEmpty ? points : scaledPoints
2666
+    }
2667
+
2668
+    private func appendScaledSegment(
2669
+        _ segment: [Measurements.Measurement.Point],
2670
+        to scaledPoints: inout [Measurements.Measurement.Point],
2671
+        displayColumns: Int
2672
+    ) {
2673
+        guard !segment.isEmpty else { return }
2674
+
2675
+        if segment.count <= max(displayColumns * 2, 120) {
2676
+            for point in segment {
2677
+                appendScaledPoint(point, to: &scaledPoints)
2678
+            }
2679
+            return
2680
+        }
2681
+
2682
+        var bucket: [Measurements.Measurement.Point] = []
2683
+        var currentColumn: Int?
2684
+
2685
+        for point in segment {
2686
+            let column = displayColumn(for: point, totalColumns: displayColumns)
2687
+
2688
+            if let currentColumn, currentColumn != column {
2689
+                appendBucket(bucket, to: &scaledPoints)
2690
+                bucket.removeAll(keepingCapacity: true)
2256 2691
             }
2692
+
2693
+            bucket.append(point)
2694
+            currentColumn = column
2695
+        }
2696
+
2697
+        appendBucket(bucket, to: &scaledPoints)
2698
+    }
2699
+
2700
+    private func appendBucket(
2701
+        _ bucket: [Measurements.Measurement.Point],
2702
+        to scaledPoints: inout [Measurements.Measurement.Point]
2703
+    ) {
2704
+        guard !bucket.isEmpty else { return }
2705
+
2706
+        if bucket.count <= 2 {
2707
+            for point in bucket {
2708
+                appendScaledPoint(point, to: &scaledPoints)
2709
+            }
2710
+            return
2711
+        }
2712
+
2713
+        let firstPoint = bucket.first!
2714
+        let lastPoint = bucket.last!
2715
+        let minimumPoint = bucket.min { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2716
+        let maximumPoint = bucket.max { lhs, rhs in lhs.value < rhs.value } ?? firstPoint
2717
+
2718
+        let orderedPoints = [firstPoint, minimumPoint, maximumPoint, lastPoint]
2719
+            .sorted { lhs, rhs in
2720
+                if lhs.timestamp == rhs.timestamp {
2721
+                    return lhs.id < rhs.id
2722
+                }
2723
+                return lhs.timestamp < rhs.timestamp
2724
+            }
2725
+
2726
+        var emittedPointIDs: Set<Int> = []
2727
+        for point in orderedPoints where emittedPointIDs.insert(point.id).inserted {
2728
+            appendScaledPoint(point, to: &scaledPoints)
2257 2729
         }
2258 2730
     }
2731
+
2732
+    private func appendScaledPoint(
2733
+        _ point: Measurements.Measurement.Point,
2734
+        to scaledPoints: inout [Measurements.Measurement.Point]
2735
+    ) {
2736
+        guard !(scaledPoints.last?.timestamp == point.timestamp &&
2737
+                scaledPoints.last?.value == point.value &&
2738
+                scaledPoints.last?.kind == point.kind) else {
2739
+            return
2740
+        }
2741
+
2742
+        scaledPoints.append(
2743
+            Measurements.Measurement.Point(
2744
+                id: scaledPoints.count,
2745
+                timestamp: point.timestamp,
2746
+                value: point.value,
2747
+                kind: point.kind
2748
+            )
2749
+        )
2750
+    }
2751
+
2752
+    private func displayColumn(
2753
+        for point: Measurements.Measurement.Point,
2754
+        totalColumns: Int
2755
+    ) -> Int {
2756
+        let totalColumns = max(totalColumns, 1)
2757
+        let timeSpan = max(Double(context.size.width), 1)
2758
+        let normalizedOffset = min(
2759
+            max((point.timestamp.timeIntervalSince1970 - Double(context.origin.x)) / timeSpan, 0),
2760
+            1
2761
+        )
2762
+
2763
+        return min(
2764
+            Int((normalizedOffset * Double(totalColumns - 1)).rounded(.down)),
2765
+            totalColumns - 1
2766
+        )
2767
+    }
2259 2768
     
2260 2769
 }
+6 -1
USB Meter/Views/Meter/Sheets/MeasurementSeries/MeasurementSeriesSheetView.swift
@@ -42,7 +42,8 @@ struct MeasurementSeriesSheetView: View {
42 42
                                 MeasurementSeriesSampleView(
43 43
                                     power: point,
44 44
                                     voltage: measurements.voltage.points[point.id],
45
-                                    current: measurements.current.points[point.id]
45
+                                    current: measurements.current.points[point.id],
46
+                                    energy: energyPoint(for: point.timestamp)
46 47
                                 )
47 48
                             }
48 49
                         }
@@ -69,4 +70,8 @@ struct MeasurementSeriesSheetView: View {
69 70
         }
70 71
         .navigationViewStyle(StackNavigationViewStyle())
71 72
     }
73
+
74
+    private func energyPoint(for timestamp: Date) -> Measurements.Measurement.Point? {
75
+        measurements.energy.samplePoints.last { $0.timestamp == timestamp }
76
+    }
72 77
 }
+4 -0
USB Meter/Views/Meter/Sheets/MeasurementSeries/Subviews/MeasurementSeriesSampleView.swift
@@ -13,6 +13,7 @@ struct MeasurementSeriesSampleView: View {
13 13
     var power: Measurements.Measurement.Point
14 14
     var voltage: Measurements.Measurement.Point
15 15
     var current: Measurements.Measurement.Point
16
+    var energy: Measurements.Measurement.Point?
16 17
 
17 18
     @State var showDetail: Bool = false
18 19
     
@@ -48,6 +49,9 @@ struct MeasurementSeriesSampleView: View {
48 49
                     detailRow(title: "Power", value: "\(power.value.format(fractionDigits: 4)) W")
49 50
                     detailRow(title: "Voltage", value: "\(voltage.value.format(fractionDigits: 4)) V")
50 51
                     detailRow(title: "Current", value: "\(current.value.format(fractionDigits: 4)) A")
52
+                    if let energy {
53
+                        detailRow(title: "Energy", value: "\(energy.value.format(fractionDigits: 4)) Wh")
54
+                    }
51 55
                 }
52 56
             }
53 57
         }
+19 -1
USB Meter/Views/Meter/Tabs/Live/Subviews/MeterLiveContentView.swift
@@ -80,6 +80,16 @@ struct MeterLiveContentView: View {
80 80
                     )
81 81
                 }
82 82
 
83
+                if shouldShowEnergyCard {
84
+                    liveMetricCard(
85
+                        title: "Energy",
86
+                        symbol: "battery.100.bolt",
87
+                        color: .teal,
88
+                        value: "\(liveBufferedEnergyValue.format(decimalDigits: 3)) Wh",
89
+                        detailText: "Buffered accumulated energy"
90
+                    )
91
+                }
92
+
83 93
                 if shouldShowTemperatureCard {
84 94
                     liveMetricCard(
85 95
                         title: "Temperature",
@@ -162,10 +172,18 @@ struct MeterLiveContentView: View {
162 172
         hasLiveMetrics && meter.measurements.power.context.isValid && meter.power.isFinite
163 173
     }
164 174
 
175
+    private var shouldShowEnergyCard: Bool {
176
+        hasLiveMetrics && meter.measurements.energy.context.isValid && liveBufferedEnergyValue.isFinite
177
+    }
178
+
165 179
     private var shouldShowTemperatureCard: Bool {
166 180
         hasLiveMetrics && meter.displayedTemperatureValue.isFinite
167 181
     }
168 182
 
183
+    private var liveBufferedEnergyValue: Double {
184
+        meter.measurements.energy.samplePoints.last?.value ?? 0
185
+    }
186
+
169 187
     private var shouldShowLoadCard: Bool {
170 188
         hasLiveMetrics && meter.loadResistance.isFinite && meter.loadResistance > 0
171 189
     }
@@ -409,7 +427,7 @@ private struct PowerAverageSheetView: View {
409 427
                     }
410 428
 
411 429
                     MeterInfoCardView(title: "Buffer Actions", tint: .secondary) {
412
-                        Text("Reset clears the captured live measurement buffer for power, voltage, current, temperature, and RSSI.")
430
+                        Text("Reset clears the captured live measurement buffer for power, energy, voltage, current, temperature, and RSSI.")
413 431
                             .font(.footnote)
414 432
                             .foregroundColor(.secondary)
415 433