Showing 14 changed files with 533 additions and 89 deletions
+6 -0
USB Meter/Model/AppData.swift
@@ -40,6 +40,7 @@ final class AppData : ObservableObject {
40 40
     }
41 41
     
42 42
     @ICloudDefault(key: "MeterNames", defaultValue: [:]) var meterNames: [String:String]
43
+    @ICloudDefault(key: "TC66TemperatureUnits", defaultValue: [:]) var tc66TemperatureUnits: [String:String]
43 44
     func test(notification: NotificationCenter.Publisher.Output) -> Void {
44 45
         if let changedKeys = notification.userInfo?["NSUbiquitousKeyValueStoreChangedKeysKey"] as? [String] {
45 46
             var somethingChanged = false
@@ -54,6 +55,11 @@ final class AppData : ObservableObject {
54 55
                             }
55 56
                         }
56 57
                     }
58
+                case "TC66TemperatureUnits":
59
+                    for meter in self.meters.values where meter.supportsManualTemperatureUnitSelection {
60
+                        meter.reloadTemperatureUnitPreference()
61
+                        somethingChanged = true
62
+                    }
57 63
                 default:
58 64
                     track("Unknown key: '\(changedKey)' changed in iCloud)")
59 65
                 }
+27 -14
USB Meter/Model/ChartContext.swift
@@ -23,7 +23,7 @@ class ChartContext {
23 23
         get {
24 24
             guard rect != nil else {
25 25
                 track("Invalid Context")
26
-                fatalError()
26
+                return .zero
27 27
             }
28 28
             return rect!.size
29 29
         }
@@ -33,7 +33,7 @@ class ChartContext {
33 33
         get {
34 34
             guard rect != nil else {
35 35
                 track("Invalid Context")
36
-                fatalError()
36
+                return .zero
37 37
             }
38 38
             return rect!.origin
39 39
         }
@@ -51,7 +51,7 @@ class ChartContext {
51 51
 
52 52
     func reset() {
53 53
         rect = nil
54
-        padding()
54
+        pad = 0
55 55
     }
56 56
     func include( point: CGPoint )  {
57 57
         if rect == nil {
@@ -67,10 +67,27 @@ class ChartContext {
67 67
         guard rect != nil else {
68 68
             track("Invalid Context")
69 69
             pad = 0
70
-            fatalError()
70
+            return
71 71
         }
72 72
         pad = rect!.size.height * Constants.chartUnderscan
73 73
     }
74
+
75
+    func ensureMinimumSize(width minimumWidth: CGFloat = 0, height minimumHeight: CGFloat = 0) {
76
+        guard var rect else { return }
77
+
78
+        if rect.width < minimumWidth {
79
+            let delta = (minimumWidth - rect.width) / 2
80
+            rect = rect.insetBy(dx: -delta, dy: 0)
81
+        }
82
+
83
+        if rect.height < minimumHeight {
84
+            let delta = (minimumHeight - rect.height) / 2
85
+            rect = rect.insetBy(dx: 0, dy: -delta)
86
+        }
87
+
88
+        self.rect = rect
89
+        padding()
90
+    }
74 91
     
75 92
     func yAxisLabel( for itemNo: Int, of items: Int ) -> Double {
76 93
         let labelSpace = Double(rect!.height) / Double(items - 1)
@@ -86,19 +103,15 @@ class ChartContext {
86 103
     }
87 104
     
88 105
     func placeInRect (point: CGPoint) -> CGPoint {
89
-        guard rect != nil else {
106
+        guard let rect else {
90 107
             track("Invalid Context")
91
-            fatalError()
92
-        }
93
-        guard rect!.width != 0 else {
94
-            fatalError()
95
-        }
96
-        guard rect!.height != 0 else {
97
-            fatalError()
108
+            return .zero
98 109
         }
99 110
 
100
-        let x = (point.x - rect!.origin.x)/rect!.width
101
-        let y = (pad + point.y - rect!.origin.y)/rect!.height
111
+        let width = max(rect.width, 1)
112
+        let height = max(rect.height, 0.1)
113
+        let x = (point.x - rect.origin.x)/width
114
+        let y = (pad + point.y - rect.origin.y)/height
102 115
         return CGPoint(x: x, y: 1 - y * Constants.chartOverscan)
103 116
     }
104 117
 }
+34 -1
USB Meter/Model/Measurements.swift
@@ -45,6 +45,20 @@ class Measurements : ObservableObject {
45 45
             context.reset()
46 46
             self.objectWillChange.send()
47 47
         }
48
+
49
+        func trim(before cutoff: Date) {
50
+            points = points
51
+                .filter { $0.timestamp >= cutoff }
52
+                .enumerated()
53
+                .map { index, point in
54
+                    Measurement.Point(id: index, timestamp: point.timestamp, value: point.value)
55
+                }
56
+            context.reset()
57
+            for point in points {
58
+                context.include(point: point.point())
59
+            }
60
+            self.objectWillChange.send()
61
+        }
48 62
     }
49 63
     
50 64
     @Published var power = Measurement()
@@ -57,6 +71,18 @@ class Measurements : ObservableObject {
57 71
     private var powerSum: Double = 0
58 72
     private var voltageSum: Double = 0
59 73
     private var currentSum: Double = 0
74
+
75
+    func reset() {
76
+        power.reset()
77
+        voltage.reset()
78
+        current.reset()
79
+        lastPointTimestamp = 0
80
+        itemsInSum = 0
81
+        powerSum = 0
82
+        voltageSum = 0
83
+        currentSum = 0
84
+        self.objectWillChange.send()
85
+    }
60 86
     
61 87
     func remove(at idx: Int) {
62 88
         power.removeValue(index: idx)
@@ -65,6 +91,13 @@ class Measurements : ObservableObject {
65 91
         self.objectWillChange.send()
66 92
     }
67 93
 
94
+    func trim(before cutoff: Date) {
95
+        power.trim(before: cutoff)
96
+        voltage.trim(before: cutoff)
97
+        current.trim(before: cutoff)
98
+        self.objectWillChange.send()
99
+    }
100
+
68 101
 
69 102
         
70 103
     func addValues(timestamp: Date, power: Double, voltage: Double, current: Double) {
@@ -74,7 +107,7 @@ class Measurements : ObservableObject {
74 107
         }
75 108
         if lastPointTimestamp == valuesTimestamp {
76 109
             itemsInSum += 1
77
-            powerSum += voltage
110
+            powerSum += power
78 111
             voltageSum += voltage
79 112
             currentSum += current
80 113
         }
+235 -1
USB Meter/Model/Meter.swift
@@ -28,6 +28,37 @@ enum Model: CaseIterable {
28 28
     case TC66C
29 29
 }
30 30
 
31
+enum TemperatureUnitPreference: String, CaseIterable, Identifiable {
32
+    case celsius
33
+    case fahrenheit
34
+
35
+    var id: String { rawValue }
36
+
37
+    var title: String {
38
+        switch self {
39
+        case .celsius:
40
+            return "Celsius"
41
+        case .fahrenheit:
42
+            return "Fahrenheit"
43
+        }
44
+    }
45
+
46
+    var symbol: String {
47
+        switch self {
48
+        case .celsius:
49
+            return "℃"
50
+        case .fahrenheit:
51
+            return "℉"
52
+        }
53
+    }
54
+}
55
+
56
+enum ChargeRecordState {
57
+    case waitingForStart
58
+    case active
59
+    case completed
60
+}
61
+
31 62
 class Meter : NSObject, ObservableObject, Identifiable {
32 63
 
33 64
     enum OperationalState: Int, Comparable {
@@ -145,19 +176,68 @@ class Meter : NSObject, ObservableObject, Identifiable {
145 176
         capabilities.supportsRecordingThreshold
146 177
     }
147 178
 
179
+    var reportsCurrentScreenIndex: Bool {
180
+        capabilities.reportsCurrentScreenIndex
181
+    }
182
+
183
+    var showsDataGroupEnergy: Bool {
184
+        capabilities.showsDataGroupEnergy
185
+    }
186
+
187
+    var highlightsActiveDataGroup: Bool {
188
+        if model == .TC66C {
189
+            return hasObservedActiveDataGroup
190
+        }
191
+        return capabilities.highlightsActiveDataGroup
192
+    }
193
+
148 194
     var supportsFahrenheit: Bool {
149 195
         capabilities.supportsFahrenheit
150 196
     }
151 197
 
198
+    var supportsManualTemperatureUnitSelection: Bool {
199
+        model == .TC66C
200
+    }
201
+
152 202
     var supportsChargerDetection: Bool {
153 203
         capabilities.supportsChargerDetection
154 204
     }
155 205
 
206
+    var dataGroupsTitle: String {
207
+        capabilities.dataGroupsTitle
208
+    }
209
+
156 210
     var chargerTypeDescription: String {
157 211
         capabilities.chargerTypeDescription(for: chargerTypeIndex)
158 212
     }
159 213
 
214
+    var temperatureUnitDescription: String {
215
+        if supportsManualTemperatureUnitSelection {
216
+            return tc66TemperatureUnitPreference.title
217
+        }
218
+        return supportsFahrenheit ? "Celsius / Fahrenheit" : "Celsius"
219
+    }
220
+
221
+    var primaryTemperatureDescription: String {
222
+        let value = temperatureCelsius.format(decimalDigits: 0)
223
+        if supportsManualTemperatureUnitSelection {
224
+            return "\(value)\(tc66TemperatureUnitPreference.symbol)"
225
+        }
226
+        if let symbol = capabilities.primaryTemperatureUnitSymbol {
227
+            return "\(value)\(symbol)"
228
+        }
229
+        return value
230
+    }
231
+
232
+    var secondaryTemperatureDescription: String? {
233
+        guard supportsFahrenheit else { return nil }
234
+        return "\(temperatureFahrenheit.format(decimalDigits: 0))℉"
235
+    }
236
+
160 237
     var currentScreenDescription: String {
238
+        guard reportsCurrentScreenIndex else {
239
+            return "Page Controls"
240
+        }
161 241
         if let label = capabilities.screenDescription(for: currentScreen) {
162 242
             return "Screen \(currentScreen): \(label)"
163 243
         }
@@ -184,8 +264,59 @@ class Meter : NSObject, ObservableObject, Identifiable {
184 264
         return String(format: "%02d:%02d", minutes, seconds)
185 265
     }
186 266
 
267
+    var chargeRecordDurationDescription: String {
268
+        let totalSeconds = Int(chargeRecordDuration)
269
+        let hours = totalSeconds / 3600
270
+        let minutes = (totalSeconds % 3600) / 60
271
+        let seconds = totalSeconds % 60
272
+
273
+        if hours > 0 {
274
+            return String(format: "%d:%02d:%02d", hours, minutes, seconds)
275
+        }
276
+        return String(format: "%02d:%02d", minutes, seconds)
277
+    }
278
+
279
+    var chargeRecordTimeRange: ClosedRange<Date>? {
280
+        guard let start = chargeRecordStartTimestamp else { return nil }
281
+        let end = chargeRecordEndTimestamp ?? chargeRecordLastTimestamp
282
+        guard let end else { return nil }
283
+        return start...end
284
+    }
285
+
286
+    var chargeRecordStatusText: String {
287
+        switch chargeRecordState {
288
+        case .waitingForStart:
289
+            return "Waiting"
290
+        case .active:
291
+            return "Active"
292
+        case .completed:
293
+            return "Completed"
294
+        }
295
+    }
296
+
297
+    var chargeRecordStatusColor: Color {
298
+        switch chargeRecordState {
299
+        case .waitingForStart:
300
+            return .secondary
301
+        case .active:
302
+            return .red
303
+        case .completed:
304
+            return .green
305
+        }
306
+    }
307
+
187 308
     var dataGroupsHint: String? {
188
-        capabilities.dataGroupsHint
309
+        if model == .TC66C {
310
+            if hasObservedActiveDataGroup {
311
+                return "The active memory is inferred from the totals that are currently increasing."
312
+            }
313
+            return "The device exposes two read-only memories. The active memory is inferred once one total starts increasing."
314
+        }
315
+        return capabilities.dataGroupsHint
316
+    }
317
+
318
+    func dataGroupLabel(for id: UInt8) -> String {
319
+        supportsDataGroupCommands ? "\(id)" : "M\(Int(id) + 1)"
189 320
     }
190 321
 
191 322
     var recordingThresholdHint: String? {
@@ -209,6 +340,19 @@ class Meter : NSObject, ObservableObject, Identifiable {
209 340
     }
210 341
     @Published var selectedDataGroup: UInt8 = 0
211 342
     @Published var dataGroupRecords: [Int : DataGroupRecord] = [:]
343
+    @Published var chargeRecordAH: Double = 0
344
+    @Published var chargeRecordWH: Double = 0
345
+    @Published var chargeRecordDuration: TimeInterval = 0
346
+    @Published var chargeRecordStopThreshold: Double = 0.05
347
+    @Published var tc66TemperatureUnitPreference: TemperatureUnitPreference = .celsius {
348
+        didSet {
349
+            guard supportsManualTemperatureUnitSelection else { return }
350
+            guard oldValue != tc66TemperatureUnitPreference else { return }
351
+            var settings = appData.tc66TemperatureUnits
352
+            settings[btSerial.macAddress.description] = tc66TemperatureUnitPreference.rawValue
353
+            appData.tc66TemperatureUnits = settings
354
+        }
355
+    }
212 356
 
213 357
     @Published var screenBrightness: Int = -1 {
214 358
         didSet {
@@ -268,6 +412,14 @@ class Meter : NSObject, ObservableObject, Identifiable {
268 412
     private var recordingThresholdTimestamp = Date()
269 413
     private var recordingThresholdLoadedFromDevice = false
270 414
     private var isApplyingRecordingThresholdFromDevice = false
415
+    @Published private(set) var chargeRecordState = ChargeRecordState.waitingForStart
416
+    private var chargeRecordStartTimestamp: Date?
417
+    private var chargeRecordEndTimestamp: Date?
418
+    private var chargeRecordLastTimestamp: Date?
419
+    private var chargeRecordLastCurrent: Double = 0
420
+    private var chargeRecordLastPower: Double = 0
421
+    private var hasObservedActiveDataGroup = false
422
+    private var hasSeenTC66Snapshot = false
271 423
         
272 424
     init ( model: Model, with serialPort: BluetoothSerial ) {
273 425
         uuid = serialPort.peripheral.identifier
@@ -278,11 +430,21 @@ class Meter : NSObject, ObservableObject, Identifiable {
278 430
         name = appData.meterNames[serialPort.macAddress.description] ?? serialPort.macAddress.description
279 431
         super.init()
280 432
         btSerial.delegate = self
433
+        reloadTemperatureUnitPreference()
281 434
         //name = dataStore.meterNames[macAddress.description] ?? peripheral.name!
282 435
         for index in stride(from: 0, through: 9, by: 1) {
283 436
             dataGroupRecords[index] = DataGroupRecord(ah:0, wh: 0)
284 437
         }
285 438
     }
439
+
440
+    func reloadTemperatureUnitPreference() {
441
+        guard supportsManualTemperatureUnitSelection else { return }
442
+        let rawValue = appData.tc66TemperatureUnits[btSerial.macAddress.description] ?? TemperatureUnitPreference.celsius.rawValue
443
+        let persistedPreference = TemperatureUnitPreference(rawValue: rawValue) ?? .celsius
444
+        if tc66TemperatureUnitPreference != persistedPreference {
445
+            tc66TemperatureUnitPreference = persistedPreference
446
+        }
447
+    }
286 448
     
287 449
     func dataDumpRequest() {
288 450
         if commandQueue.isEmpty {
@@ -333,6 +495,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
333 495
                 track("\(name) - Error: \(error)")
334 496
             }
335 497
         }
498
+        updateChargeRecord(at: dataDumpRequestTimestamp)
336 499
         measurements.addValues(timestamp: dataDumpRequestTimestamp, power: power, voltage: voltage, current: current)
337 500
 //        DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
338 501
 //            //track("\(name) - Scheduled new request.")
@@ -392,6 +555,11 @@ class Meter : NSObject, ObservableObject, Identifiable {
392 555
     }
393 556
 
394 557
     private func apply(tc66Snapshot snapshot: TC66Snapshot) {
558
+        if hasSeenTC66Snapshot {
559
+            inferTC66ActiveDataGroup(from: snapshot)
560
+        } else {
561
+            hasSeenTC66Snapshot = true
562
+        }
395 563
         reportedModelName = snapshot.modelName
396 564
         firmwareVersion = snapshot.firmwareVersion
397 565
         serialNumber = snapshot.serialNumber
@@ -407,6 +575,72 @@ class Meter : NSObject, ObservableObject, Identifiable {
407 575
         usbPlusVoltage = snapshot.usbPlusVoltage
408 576
         usbMinusVoltage = snapshot.usbMinusVoltage
409 577
     }
578
+
579
+    private func inferTC66ActiveDataGroup(from snapshot: TC66Snapshot) {
580
+        let candidate = snapshot.dataGroupRecords.compactMap { entry -> (UInt8, Double)? in
581
+            let index = entry.key
582
+            let record = entry.value
583
+            guard let previous = dataGroupRecords[index] else { return nil }
584
+            let deltaAH = max(record.ah - previous.ah, 0)
585
+            let deltaWH = max(record.wh - previous.wh, 0)
586
+            let score = deltaAH + deltaWH
587
+            guard score > 0 else { return nil }
588
+            return (UInt8(index), score)
589
+        }
590
+        .max { lhs, rhs in lhs.1 < rhs.1 }
591
+
592
+        if let candidate {
593
+            selectedDataGroup = candidate.0
594
+            hasObservedActiveDataGroup = true
595
+        }
596
+    }
597
+
598
+    private func updateChargeRecord(at timestamp: Date) {
599
+        switch chargeRecordState {
600
+        case .waitingForStart:
601
+            guard current > chargeRecordStopThreshold else { return }
602
+            chargeRecordState = .active
603
+            chargeRecordStartTimestamp = timestamp
604
+            chargeRecordEndTimestamp = timestamp
605
+            chargeRecordLastTimestamp = timestamp
606
+            chargeRecordLastCurrent = current
607
+            chargeRecordLastPower = power
608
+        case .active:
609
+            if let lastTimestamp = chargeRecordLastTimestamp {
610
+                let deltaSeconds = max(timestamp.timeIntervalSince(lastTimestamp), 0)
611
+                chargeRecordAH += chargeRecordLastCurrent * deltaSeconds / 3600
612
+                chargeRecordWH += chargeRecordLastPower * deltaSeconds / 3600
613
+                chargeRecordDuration += deltaSeconds
614
+            }
615
+            chargeRecordEndTimestamp = timestamp
616
+            chargeRecordLastTimestamp = timestamp
617
+            chargeRecordLastCurrent = current
618
+            chargeRecordLastPower = power
619
+            if current <= chargeRecordStopThreshold {
620
+                chargeRecordState = .completed
621
+            }
622
+        case .completed:
623
+            break
624
+        }
625
+    }
626
+
627
+    func resetChargeRecord() {
628
+        chargeRecordAH = 0
629
+        chargeRecordWH = 0
630
+        chargeRecordDuration = 0
631
+        chargeRecordState = .waitingForStart
632
+        chargeRecordStartTimestamp = nil
633
+        chargeRecordEndTimestamp = nil
634
+        chargeRecordLastTimestamp = nil
635
+        chargeRecordLastCurrent = 0
636
+        chargeRecordLastPower = 0
637
+    }
638
+
639
+    func resetChargeRecordGraph() {
640
+        let cutoff = Date()
641
+        resetChargeRecord()
642
+        measurements.trim(before: cutoff)
643
+    }
410 644
         
411 645
     func nextScreen() {
412 646
         switch model {
+19 -4
USB Meter/Model/MeterCapabilities.swift
@@ -13,8 +13,13 @@ struct MeterCapabilities {
13 13
     let supportsRecordingView: Bool
14 14
     let supportsScreenSettings: Bool
15 15
     let supportsRecordingThreshold: Bool
16
+    let reportsCurrentScreenIndex: Bool
17
+    let showsDataGroupEnergy: Bool
18
+    let highlightsActiveDataGroup: Bool
16 19
     let supportsFahrenheit: Bool
17 20
     let supportsChargerDetection: Bool
21
+    let primaryTemperatureUnitSymbol: String?
22
+    let dataGroupsTitle: String
18 23
     let chargerTypeDescriptions: [UInt16: String]
19 24
     let screenDescriptions: [UInt16: String]
20 25
     let dataGroupsHint: String?
@@ -40,8 +45,13 @@ extension MeterCapabilities {
40 45
         supportsRecordingView: true,
41 46
         supportsScreenSettings: true,
42 47
         supportsRecordingThreshold: true,
48
+        reportsCurrentScreenIndex: true,
49
+        showsDataGroupEnergy: true,
50
+        highlightsActiveDataGroup: true,
43 51
         supportsFahrenheit: true,
44 52
         supportsChargerDetection: true,
53
+        primaryTemperatureUnitSymbol: "℃",
54
+        dataGroupsTitle: "Data Groups",
45 55
         chargerTypeDescriptions: [
46 56
             1: "QC2",
47 57
             2: "QC3",
@@ -60,21 +70,26 @@ extension MeterCapabilities {
60 70
             4: "Graphing",
61 71
             5: "System Settings"
62 72
         ],
63
-        dataGroupsHint: "Group 0 is temporary. Groups 1-9 persist across power cycles.",
64
-        recordingThresholdHint: "Recording starts automatically when current rises above this threshold."
73
+        dataGroupsHint: "The active group is reported by the meter. Group 0 is temporary. Groups 1-9 persist across power cycles.",
74
+        recordingThresholdHint: "The meter starts its built-in charge record when current rises above this threshold."
65 75
     )
66 76
 
67 77
     static let tc66c = MeterCapabilities(
68 78
         availableDataGroupIDs: [0, 1],
69 79
         supportsDataGroupCommands: false,
70
-        supportsRecordingView: false,
80
+        supportsRecordingView: true,
71 81
         supportsScreenSettings: false,
72 82
         supportsRecordingThreshold: false,
83
+        reportsCurrentScreenIndex: false,
84
+        showsDataGroupEnergy: true,
85
+        highlightsActiveDataGroup: false,
73 86
         supportsFahrenheit: false,
74 87
         supportsChargerDetection: false,
88
+        primaryTemperatureUnitSymbol: nil,
89
+        dataGroupsTitle: "Memory Totals",
75 90
         chargerTypeDescriptions: [:],
76 91
         screenDescriptions: [:],
77
-        dataGroupsHint: nil,
92
+        dataGroupsHint: "The device exposes two read-only memories with charge and energy totals. The active memory is not reported.",
78 93
         recordingThresholdHint: nil
79 94
     )
80 95
 }
+2 -2
USB Meter/Model/TC66Protocol.swift
@@ -89,7 +89,7 @@ enum TC66Protocol {
89 89
             let offset = 8 + index * 8
90 90
             dataGroupRecords[index] = TC66DataGroupTotals(
91 91
                 ah: Double(UInt32(littleEndian: pac2.value(from: offset))) / 1000,
92
-                wh: Double(UInt32(littleEndian: pac2.value(from: offset + 40))) / 1000
92
+                wh: Double(UInt32(littleEndian: pac2.value(from: offset + 4))) / 1000
93 93
             )
94 94
         }
95 95
 
@@ -104,7 +104,7 @@ enum TC66Protocol {
104 104
             voltage: Double(UInt32(littleEndian: pac1.value(from: 48))) / 10000,
105 105
             current: Double(UInt32(littleEndian: pac1.value(from: 52))) / 100000,
106 106
             power: Double(UInt32(littleEndian: pac1.value(from: 56))) / 10000,
107
-            loadResistance: Double(UInt32(littleEndian: pac2.value(from: 4))) / 10,
107
+            loadResistance: Double(UInt32(littleEndian: pac2.value(from: 4))) / 100,
108 108
             dataGroupRecords: dataGroupRecords,
109 109
             temperatureCelsius: temperatureMagnitude * temperatureSign,
110 110
             usbPlusVoltage: Double(UInt32(littleEndian: pac2.value(from: 32))) / 100,
+6 -0
USB Meter/Views/Meter/ControlView.swift
@@ -23,6 +23,12 @@ struct ControlView: View {
23 23
                     Text(meter.currentScreenDescription)
24 24
                     Button(action: { self.meter.nextScreen() }, label: { Image(systemName: "arrowtriangle.right") })
25 25
                 }
26
+                if !meter.reportsCurrentScreenIndex {
27
+                    Text("This protocol supports page navigation, but not current-page reporting.")
28
+                        .font(.footnote)
29
+                        .foregroundColor(.secondary)
30
+                        .multilineTextAlignment(.center)
31
+                }
26 32
             }
27 33
         }
28 34
     }
+24 -12
USB Meter/Views/Meter/Data Groups/DataGroupRowView.swift
@@ -14,14 +14,20 @@ struct DataGroupRowView: View {
14 14
     var id: UInt8
15 15
     var width: CGFloat
16 16
     var opacity: Double
17
+    var showsCommands: Bool
18
+    var showsEnergy: Bool
19
+    var highlightsSelection: Bool
17 20
     
18 21
     @EnvironmentObject private var usbMeter: Meter
19 22
     
20 23
     var body: some View {
21 24
         HStack (spacing: 1) {
22 25
             ZStack {
23
-                Button(action: { self.usbMeter.selectDataGroup(id: self.id) }, label: { Image(systemName: "\(id).circle") })
24
-                    .disabled(!usbMeter.supportsDataGroupCommands)
26
+                if showsCommands {
27
+                    Button(action: { self.usbMeter.selectDataGroup(id: self.id) }, label: { Image(systemName: "\(id).circle") })
28
+                } else {
29
+                    Text(usbMeter.dataGroupLabel(for: id))
30
+                }
25 31
                 Rectangle().opacity( opacity )
26 32
             }.frame(width: width)
27 33
             
@@ -30,17 +36,23 @@ struct DataGroupRowView: View {
30 36
                 Rectangle().opacity( opacity )
31 37
             }.frame(width: width)
32 38
             
33
-            ZStack {
34
-                Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
35
-                Rectangle().opacity( opacity )
36
-            }.frame(width: width)
39
+            if showsEnergy {
40
+                ZStack {
41
+                    Text("\(usbMeter.dataGroupRecords[Int(id)]!.wh.format(decimalDigits: 3))")
42
+                    Rectangle().opacity( opacity )
43
+                }.frame(width: width)
44
+            }
37 45
             
38
-            ZStack {
39
-                Button(action: { self.usbMeter.clear(group: self.id) }, label: { Image(systemName: "bin.xmark") })
40
-                    .disabled(!usbMeter.supportsDataGroupCommands)
41
-                Rectangle().opacity( opacity )
42
-            }.frame(width: width)
46
+            if showsCommands {
47
+                ZStack {
48
+                    Button(action: { self.usbMeter.clear(group: self.id) }, label: { Image(systemName: "bin.xmark") })
49
+                    Rectangle().opacity( opacity )
50
+                }.frame(width: width)
51
+            }
43 52
         }
44
-        .background(BorderView(show: usbMeter.selectedDataGroup == id))
53
+        .overlay(
54
+            RoundedRectangle(cornerRadius: 10)
55
+                .stroke(highlightsSelection && usbMeter.selectedDataGroup == id ? Color.accentColor : Color.clear, lineWidth: 3)
56
+        )
45 57
     }
46 58
 }
+15 -4
USB Meter/Views/Meter/Data Groups/DataGroupsView.swift
@@ -16,13 +16,17 @@ struct DataGroupsView: View {
16 16
     
17 17
     var body: some View {
18 18
         GeometryReader { box in
19
+            let columnTitles = [usbMeter.supportsDataGroupCommands ? "Group" : "Memory", "Ah"]
20
+                + (usbMeter.showsDataGroupEnergy ? ["Wh"] : [])
21
+                + (usbMeter.supportsDataGroupCommands ? ["Clear"] : [])
22
+            let columnWidth = (box.size.width - 25) / CGFloat(columnTitles.count)
19 23
             let tableReservedHeight: CGFloat = usbMeter.dataGroupsHint == nil ? 100 : 140
20 24
             let rowCount = CGFloat(usbMeter.availableDataGroupIDs.count + 1)
21 25
             let rowHeight = (box.size.height - tableReservedHeight) / rowCount
22 26
 
23 27
             VStack (spacing: 1) {
24 28
                 HStack {
25
-                    Text("Data Groups")
29
+                    Text(usbMeter.dataGroupsTitle)
26 30
                         .bold()
27 31
                     Spacer()
28 32
                     Button(action: {self.visibility.toggle()}) {
@@ -43,13 +47,20 @@ struct DataGroupsView: View {
43 47
                 }
44 48
                 
45 49
                 HStack (spacing: 1) {
46
-                    ForEach (["Group", "Ah", "Wh", "Clear"], id: \.self ) { text in
47
-                        self.THView( text: text, width: (box.size.width-25)/4 )
50
+                    ForEach(columnTitles, id: \.self ) { text in
51
+                        self.THView(text: text, width: columnWidth)
48 52
                     }
49 53
                 }
50 54
                 .frame(height: rowHeight)
51 55
                 ForEach(usbMeter.availableDataGroupIDs, id: \.self) { groupId in
52
-                    DataGroupRowView(id: groupId, width: ((box.size.width-25) / 4), opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2)
56
+                    DataGroupRowView(
57
+                        id: groupId,
58
+                        width: columnWidth,
59
+                        opacity: groupId.isMultiple(of: 2) ? 0.1 : 0.2,
60
+                        showsCommands: usbMeter.supportsDataGroupCommands,
61
+                        showsEnergy: usbMeter.showsDataGroupEnergy,
62
+                        highlightsSelection: usbMeter.highlightsActiveDataGroup
63
+                    )
53 64
                 }
54 65
                 .frame(height: rowHeight)
55 66
             }
+4 -4
USB Meter/Views/Meter/LiveView.swift
@@ -23,7 +23,7 @@ struct LiveView: View {
23 23
                     Text("Power:")
24 24
                     Text("Load")
25 25
                     Text("Temperature:")
26
-                    if meter.supportsFahrenheit {
26
+                    if meter.secondaryTemperatureDescription != nil {
27 27
                         Text("")
28 28
                     }
29 29
                     Text("USB Data+:")
@@ -49,9 +49,9 @@ struct LiveView: View {
49 49
                         Text("\(meter.measurements.power.context.maxValue.format(decimalDigits: 3))W")
50 50
                     }
51 51
                     Text("\(meter.loadResistance.format(decimalDigits: 1))Ω")
52
-                    Text("\(meter.temperatureCelsius)℃")
53
-                    if meter.supportsFahrenheit {
54
-                        Text("\(meter.temperatureFahrenheit)℉")
52
+                    Text(meter.primaryTemperatureDescription)
53
+                    if let secondaryTemperatureDescription = meter.secondaryTemperatureDescription {
54
+                        Text(secondaryTemperatureDescription)
55 55
                     }
56 56
                     Text("\(meter.usbPlusVoltage.format(decimalDigits: 2))V")
57 57
                     Text("\(meter.usbMinusVoltage.format(decimalDigits: 2))V")
+68 -18
USB Meter/Views/Meter/Measurements/Chart/MeasurementChartView.swift
@@ -9,8 +9,13 @@
9 9
 import SwiftUI
10 10
 
11 11
 struct MeasurementChartView: View {
12
+    private let minimumTimeSpan: TimeInterval = 1
13
+    private let minimumVoltageSpan = 0.1
14
+    private let minimumCurrentSpan = 0.1
15
+    private let minimumPowerSpan = 0.1
12 16
     
13 17
     @EnvironmentObject private var measurements: Measurements
18
+    var timeRange: ClosedRange<Date>? = nil
14 19
     
15 20
     @State var displayVoltage: Bool = false
16 21
     @State var displayCurrent: Bool = false
@@ -19,8 +24,16 @@ struct MeasurementChartView: View {
19 24
     let yLabels: Int = 4
20 25
 
21 26
     var body: some View {
27
+        let powerSeries = series(for: measurements.power, minimumYSpan: minimumPowerSpan)
28
+        let voltageSeries = series(for: measurements.voltage, minimumYSpan: minimumVoltageSpan)
29
+        let currentSeries = series(for: measurements.current, minimumYSpan: minimumCurrentSpan)
30
+        let primarySeries = displayedPrimarySeries(
31
+            powerSeries: powerSeries,
32
+            voltageSeries: voltageSeries,
33
+            currentSeries: currentSeries
34
+        )
35
+
22 36
         Group {
23
-            //if measurements.power.points.count > 0 {
24 37
             VStack {
25 38
                 HStack {
26 39
                     Button( action: {
@@ -47,37 +60,34 @@ struct MeasurementChartView: View {
47 60
                         .asEnableFeatureButton(state: displayPower)
48 61
                 }
49 62
                 .padding(.bottom, 5)
50
-                if measurements.current.context.isValid {
63
+                if let primarySeries {
51 64
                     VStack {
52 65
                         GeometryReader { geometry in
53 66
                             HStack {
54 67
                                 Group { // MARK: Left Legend
55 68
                                     if self.displayPower {
56
-                                        self.yAxisLabelsView(geometry: geometry, context: self.measurements.power.context, measurementUnit: "W")
69
+                                        self.yAxisLabelsView(geometry: geometry, context: powerSeries.context, measurementUnit: "W")
57 70
                                             .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .red, opacity: 0.5)
58 71
                                     } else if self.displayVoltage {
59
-                                        self.yAxisLabelsView(geometry: geometry, context: self.measurements.voltage.context, measurementUnit: "V")
72
+                                        self.yAxisLabelsView(geometry: geometry, context: voltageSeries.context, measurementUnit: "V")
60 73
                                             .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .green, opacity: 0.5)
61 74
                                     }
62 75
                                     else if self.displayCurrent {
63
-                                        self.yAxisLabelsView(geometry: geometry, context: self.measurements.current.context, measurementUnit: "A")
76
+                                        self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
64 77
                                             .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
65 78
                                     }
66 79
                                 }
67 80
                                 ZStack { // MARK: Graph
68 81
                                     if self.displayPower {
69
-                                        Chart(strokeColor: .red)
70
-                                            .environmentObject(self.measurements.power)
82
+                                        Chart(points: powerSeries.points, context: powerSeries.context, strokeColor: .red)
71 83
                                             .opacity(0.5)
72 84
                                     } else {
73 85
                                         if self.displayVoltage{
74
-                                            Chart(strokeColor: .green)
75
-                                                .environmentObject(self.measurements.voltage)
86
+                                            Chart(points: voltageSeries.points, context: voltageSeries.context, strokeColor: .green)
76 87
                                                 .opacity(0.5)
77 88
                                         }
78 89
                                         if self.displayCurrent{
79
-                                            Chart(strokeColor: .blue)
80
-                                                .environmentObject(self.measurements.current)
90
+                                            Chart(points: currentSeries.points, context: currentSeries.context, strokeColor: .blue)
81 91
                                                 .opacity(0.5)
82 92
                                         }
83 93
                                     }
@@ -88,13 +98,13 @@ struct MeasurementChartView: View {
88 98
                                 }
89 99
                                 .withRoundedRectangleBackground( cornerRadius: 0, foregroundColor: .primary, opacity: 0.06 )
90 100
                                 Group { // MARK: Right Legend
91
-                                    self.yAxisLabelsView(geometry: geometry, context: self.measurements.current.context, measurementUnit: "A")
101
+                                    self.yAxisLabelsView(geometry: geometry, context: currentSeries.context, measurementUnit: "A")
92 102
                                         .foregroundColor(self.displayVoltage && self.displayCurrent ? .primary : .clear)
93 103
                                         .withRoundedRectangleBackground(cornerRadius: 0, foregroundColor: .blue, opacity: 0.5)
94 104
                                 }
95 105
                             }
96 106
                         }
97
-                        xAxisLabelsView(context: self.measurements.current.context)
107
+                        xAxisLabelsView(context: primarySeries.context)
98 108
                             .padding(.horizontal, 10)
99 109
                         
100 110
                     }
@@ -111,6 +121,44 @@ struct MeasurementChartView: View {
111 121
             .padding()
112 122
         }
113 123
     }
124
+
125
+    private func displayedPrimarySeries(
126
+        powerSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
127
+        voltageSeries: (points: [Measurements.Measurement.Point], context: ChartContext),
128
+        currentSeries: (points: [Measurements.Measurement.Point], context: ChartContext)
129
+    ) -> (points: [Measurements.Measurement.Point], context: ChartContext)? {
130
+        if displayPower {
131
+            return powerSeries.points.isEmpty ? nil : powerSeries
132
+        }
133
+        if displayVoltage {
134
+            return voltageSeries.points.isEmpty ? nil : voltageSeries
135
+        }
136
+        if displayCurrent {
137
+            return currentSeries.points.isEmpty ? nil : currentSeries
138
+        }
139
+        return nil
140
+    }
141
+
142
+    private func series(
143
+        for measurement: Measurements.Measurement,
144
+        minimumYSpan: Double
145
+    ) -> (points: [Measurements.Measurement.Point], context: ChartContext) {
146
+        let points = measurement.points.filter { point in
147
+            guard let timeRange else { return true }
148
+            return timeRange.contains(point.timestamp)
149
+        }
150
+        let context = ChartContext()
151
+        for point in points {
152
+            context.include(point: point.point())
153
+        }
154
+        if !points.isEmpty {
155
+            context.ensureMinimumSize(
156
+                width: CGFloat(minimumTimeSpan),
157
+                height: CGFloat(minimumYSpan)
158
+            )
159
+        }
160
+        return (points, context)
161
+    }
114 162
     
115 163
     // MARK: Cu iteratie nu functioneaza deoarece view-ul din bucla nu este reimprospatat la modificare obiectului observat
116 164
     fileprivate func xAxisLabelsView(context: ChartContext) -> some View {
@@ -189,7 +237,8 @@ struct MeasurementChartView: View {
189 237
 
190 238
 struct Chart : View {
191 239
     
192
-    @EnvironmentObject private var measurement: Measurements.Measurement
240
+    let points: [Measurements.Measurement.Point]
241
+    let context: ChartContext
193 242
     var areaChart: Bool = false
194 243
     var strokeColor: Color = .black
195 244
     
@@ -207,14 +256,15 @@ struct Chart : View {
207 256
     
208 257
     fileprivate func path(geometry: GeometryProxy) -> Path {
209 258
         return Path { path in
210
-            let firstPoint = measurement.context.placeInRect(point: measurement.points.first!.point())
259
+            guard let first = points.first else { return }
260
+            let firstPoint = context.placeInRect(point: first.point())
211 261
             path.move(to: CGPoint(x: firstPoint.x * geometry.size.width, y: firstPoint.y * geometry.size.height ) )
212
-            for item in measurement.points.map({ measurement.context.placeInRect(point: $0.point()) }) {
262
+            for item in points.map({ context.placeInRect(point: $0.point()) }) {
213 263
                 path.addLine(to: CGPoint(x: item.x * geometry.size.width, y: item.y * geometry.size.height ) )
214 264
             }
215 265
             if self.areaChart {
216
-                let lastPointX = measurement.context.placeInRect(point: CGPoint(x: measurement.points.last!.point().x, y: measurement.context.origin.y ))
217
-                let firstPointX = measurement.context.placeInRect(point: CGPoint(x: measurement.points.first!.point().x, y: measurement.context.origin.y ))
266
+                let lastPointX = context.placeInRect(point: CGPoint(x: points.last!.point().x, y: context.origin.y ))
267
+                let firstPointX = context.placeInRect(point: CGPoint(x: points.first!.point().x, y: context.origin.y ))
218 268
                 path.addLine(to: CGPoint(x: lastPointX.x * geometry.size.width, y: lastPointX.y * geometry.size.height ) )
219 269
                 path.addLine(to: CGPoint(x: firstPointX.x * geometry.size.width, y: firstPointX.y * geometry.size.height ) )
220 270
                 // MARK: Nu e nevoie. Fill inchide automat calea
+17 -0
USB Meter/Views/Meter/MeterSettingsView.swift
@@ -40,6 +40,7 @@ struct MeterSettingsView: View {
40 40
                         Text("Device Info").fontWeight(.semibold)
41 41
                         DeviceInfoRow(label: "Advertised Model", value: meter.modelString)
42 42
                         DeviceInfoRow(label: "Displayed Model", value: meter.deviceModelSummary)
43
+                        DeviceInfoRow(label: "Temperature Unit", value: meter.temperatureUnitDescription)
43 44
                         if meter.modelNumber != 0 {
44 45
                             DeviceInfoRow(label: "Model Code", value: "\(meter.modelNumber)")
45 46
                         }
@@ -56,6 +57,22 @@ struct MeterSettingsView: View {
56 57
                     .padding()
57 58
                     .background(RoundedRectangle(cornerRadius: 15).foregroundColor(.secondary).opacity(0.1))
58 59
                 }
60
+                if meter.operationalState == .dataIsAvailable && meter.supportsManualTemperatureUnitSelection {
61
+                    VStack(alignment: .leading, spacing: 8) {
62
+                        Text("Temperature Unit").fontWeight(.semibold)
63
+                        Text("TC66 reports temperature using the unit selected on the device. Keep this setting matched to the meter.")
64
+                            .font(.footnote)
65
+                            .foregroundColor(.secondary)
66
+                        Picker("", selection: $meter.tc66TemperatureUnitPreference) {
67
+                            ForEach(TemperatureUnitPreference.allCases) { unit in
68
+                                Text(unit.title).tag(unit)
69
+                            }
70
+                        }
71
+                        .pickerStyle(SegmentedPickerStyle())
72
+                    }
73
+                    .padding()
74
+                    .background(RoundedRectangle(cornerRadius: 15).foregroundColor(.secondary).opacity(0.1))
75
+                }
59 76
                 if meter.operationalState == .dataIsAvailable && meter.supportsUMSettings {
60 77
                     // MARK: Screen Timeout
61 78
                     // Ar trebui separat enabled/disabled de valorile in minute eventual stocata valoarea in iCloud la dezactivare pentru restaurare
+2 -2
USB Meter/Views/Meter/MeterView.swift
@@ -45,7 +45,7 @@ struct MeterView: View {
45 45
             if ( meter.operationalState ==  .dataIsAvailable) {
46 46
                 Text("Model: \(meter.deviceModelSummary)")
47 47
                 HStack(spacing: 24) {
48
-                    meterSheetButton(icon: "map", title: "Data Groups") {
48
+                    meterSheetButton(icon: "map", title: meter.dataGroupsTitle) {
49 49
                         dataGroupsViewVisibility.toggle()
50 50
                     }
51 51
                     .sheet(isPresented: self.$dataGroupsViewVisibility) {
@@ -54,7 +54,7 @@ struct MeterView: View {
54 54
                     }
55 55
 
56 56
                     if meter.supportsRecordingView {
57
-                        meterSheetButton(icon: "record.circle", title: "Recording") {
57
+                        meterSheetButton(icon: "record.circle", title: "Charge Record") {
58 58
                             recordingViewVisibility.toggle()
59 59
                         }
60 60
                         .sheet(isPresented: self.$recordingViewVisibility) {
+74 -27
USB Meter/Views/Meter/RecordingView.swift
@@ -18,10 +18,10 @@ struct RecordingView: View {
18 18
             ScrollView {
19 19
                 VStack(spacing: 16) {
20 20
                     VStack(spacing: 6) {
21
-                        Text("Device Recording")
21
+                        Text("Charge Record")
22 22
                             .font(.headline)
23
-                        Text(usbMeter.recording ? "Active" : "Idle")
24
-                            .foregroundColor(usbMeter.recording ? .red : .secondary)
23
+                        Text(usbMeter.chargeRecordStatusText)
24
+                            .foregroundColor(usbMeter.chargeRecordStatusColor)
25 25
                     }
26 26
                     .frame(maxWidth: .infinity)
27 27
                     .padding()
@@ -32,48 +32,95 @@ struct RecordingView: View {
32 32
                             Text("Capacity")
33 33
                             Text("Energy")
34 34
                             Text("Duration")
35
-                            Text("Start Threshold")
35
+                            Text("Stop Threshold")
36 36
                         }
37 37
                         Spacer()
38 38
                         VStack(alignment: .trailing, spacing: 10) {
39
-                            Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
40
-                            Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
41
-                            Text(usbMeter.recordingDurationDescription)
42
-                            if usbMeter.supportsRecordingThreshold {
43
-                                Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
44
-                            } else {
45
-                                Text("Read-only")
46
-                            }
39
+                            Text("\(usbMeter.chargeRecordAH.format(decimalDigits: 3)) Ah")
40
+                            Text("\(usbMeter.chargeRecordWH.format(decimalDigits: 3)) Wh")
41
+                            Text(usbMeter.chargeRecordDurationDescription)
42
+                            Text("\(usbMeter.chargeRecordStopThreshold.format(decimalDigits: 2)) A")
47 43
                         }
48 44
                     }
49 45
                     .padding()
50 46
                     .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1))
51 47
 
52
-                    if usbMeter.supportsRecordingThreshold {
53
-                        VStack(alignment: .leading, spacing: 10) {
54
-                            Text("Start Threshold")
55
-                                .fontWeight(.semibold)
56
-                            Slider(value: $usbMeter.recordingTreshold, in: 0...0.30, step: 0.01)
57
-                            if let hint = usbMeter.recordingThresholdHint {
58
-                                Text(hint)
59
-                                    .font(.footnote)
60
-                                    .foregroundColor(.secondary)
48
+                    if usbMeter.chargeRecordTimeRange != nil {
49
+                        VStack(alignment: .leading, spacing: 12) {
50
+                            HStack {
51
+                                Text("Charge Curve")
52
+                                    .fontWeight(.semibold)
53
+                                Spacer()
54
+                                Button("Reset Graph") {
55
+                                    usbMeter.resetChargeRecordGraph()
56
+                                }
61 57
                             }
58
+                            MeasurementChartView(timeRange: usbMeter.chargeRecordTimeRange)
59
+                                .environmentObject(usbMeter.measurements)
60
+                                .frame(minHeight: 220)
61
+                            Text("Reset Graph clears the current charge-record session and removes older shared samples that are no longer needed for this curve.")
62
+                                .font(.footnote)
63
+                                .foregroundColor(.secondary)
62 64
                         }
63 65
                         .padding()
64 66
                         .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1))
65
-                    } else {
66
-                        Text("This model reports recording totals, but the app does not expose remote threshold control for it.")
67
+                    }
68
+
69
+                    VStack(alignment: .leading, spacing: 12) {
70
+                        Text("Stop Threshold")
71
+                            .fontWeight(.semibold)
72
+                        Slider(value: $usbMeter.chargeRecordStopThreshold, in: 0...0.30, step: 0.01)
73
+                        Text("The app starts accumulating when current rises above this threshold and stops when it falls back to or below it.")
67 74
                             .font(.footnote)
68 75
                             .foregroundColor(.secondary)
69
-                            .multilineTextAlignment(.center)
70
-                            .padding()
71
-                            .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1))
76
+                        Button("Reset") {
77
+                            usbMeter.resetChargeRecord()
78
+                        }
79
+                        .frame(maxWidth: .infinity)
80
+                    }
81
+                    .padding()
82
+                    .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1))
83
+
84
+                    if usbMeter.supportsDataGroupCommands || usbMeter.recordedAH > 0 || usbMeter.recordedWH > 0 || usbMeter.recordingDuration > 0 {
85
+                        VStack(alignment: .leading, spacing: 12) {
86
+                            Text("Meter Totals")
87
+                                .fontWeight(.semibold)
88
+                            HStack(alignment: .top) {
89
+                                VStack(alignment: .leading, spacing: 10) {
90
+                                    Text("Capacity")
91
+                                    Text("Energy")
92
+                                    Text("Duration")
93
+                                    Text("Meter Threshold")
94
+                                }
95
+                                Spacer()
96
+                                VStack(alignment: .trailing, spacing: 10) {
97
+                                    Text("\(usbMeter.recordedAH.format(decimalDigits: 3)) Ah")
98
+                                    Text("\(usbMeter.recordedWH.format(decimalDigits: 3)) Wh")
99
+                                    Text(usbMeter.recordingDurationDescription)
100
+                                    if usbMeter.supportsRecordingThreshold {
101
+                                        Text("\(usbMeter.recordingTreshold.format(decimalDigits: 2)) A")
102
+                                    } else {
103
+                                        Text("Read-only")
104
+                                    }
105
+                                }
106
+                            }
107
+                            Text("These values are reported by the meter for the active data group.")
108
+                                .font(.footnote)
109
+                                .foregroundColor(.secondary)
110
+                            if usbMeter.supportsDataGroupCommands {
111
+                                Button("Reset Active Group") {
112
+                                    usbMeter.clear()
113
+                                }
114
+                                .frame(maxWidth: .infinity)
115
+                            }
116
+                        }
117
+                        .padding()
118
+                        .background(RoundedRectangle(cornerRadius: 16).foregroundColor(.secondary).opacity(0.1))
72 119
                     }
73 120
                 }
74 121
                 .padding()
75 122
             }
76
-            .navigationBarTitle("Device Recording", displayMode: .inline)
123
+            .navigationBarTitle("Charge Record", displayMode: .inline)
77 124
             .navigationBarItems(trailing: Button("Done") { visibility.toggle() })
78 125
         }
79 126
         .navigationViewStyle(StackNavigationViewStyle())