Showing 3 changed files with 201 additions and 112 deletions
+88 -2
USB Meter/Model/Meter.swift
@@ -493,8 +493,13 @@ class Meter : NSObject, ObservableObject, Identifiable {
493 493
     private var chargeRecordLastTimestamp: Date?
494 494
     private var chargeRecordLastCurrent: Double = 0
495 495
     private var chargeRecordLastPower: Double = 0
496
+    private let volatileMemoryDecreaseEpsilon = 0.0005
497
+    private let initiatedVolatileMemoryResetGraceWindow: TimeInterval = 12
498
+    private var hasSeenUMSnapshot = false
496 499
     private var hasObservedActiveDataGroup = false
497 500
     private var hasSeenTC66Snapshot = false
501
+    private var pendingVolatileMemoryResetIgnoreCount = 0
502
+    private var pendingVolatileMemoryResetDeadline: Date?
498 503
         
499 504
     init ( model: Model, with serialPort: BluetoothSerial ) {
500 505
         uuid = serialPort.peripheral.identifier
@@ -540,6 +545,82 @@ class Meter : NSObject, ObservableObject, Identifiable {
540 545
         track("\(name) - Schedule data request in \(String(format: "%.2f", delay))s (\(reason))")
541 546
         DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
542 547
     }
548
+
549
+    private func noteInitiatedVolatileMemoryResetIfNeeded(for groupID: UInt8) {
550
+        guard groupID == 0 else { return }
551
+        pendingVolatileMemoryResetIgnoreCount += 1
552
+        pendingVolatileMemoryResetDeadline = Date().addingTimeInterval(initiatedVolatileMemoryResetGraceWindow)
553
+        track("\(name) - Will ignore the next volatile memory drop caused by an app reset request.")
554
+    }
555
+
556
+    private func pendingInitiatedVolatileMemoryResetIsActive(at timestamp: Date) -> Bool {
557
+        guard let pendingVolatileMemoryResetDeadline else { return false }
558
+        guard pendingVolatileMemoryResetIgnoreCount > 0 else {
559
+            self.pendingVolatileMemoryResetDeadline = nil
560
+            return false
561
+        }
562
+        guard timestamp <= pendingVolatileMemoryResetDeadline else {
563
+            track("\(name) - Expiring stale volatile memory reset ignore state.")
564
+            pendingVolatileMemoryResetIgnoreCount = 0
565
+            self.pendingVolatileMemoryResetDeadline = nil
566
+            return false
567
+        }
568
+        return true
569
+    }
570
+
571
+    private func shouldIgnoreVolatileMemoryDrop(at timestamp: Date) -> Bool {
572
+        guard pendingInitiatedVolatileMemoryResetIsActive(at: timestamp) else { return false }
573
+        pendingVolatileMemoryResetIgnoreCount -= 1
574
+        if pendingVolatileMemoryResetIgnoreCount == 0 {
575
+            pendingVolatileMemoryResetDeadline = nil
576
+        }
577
+        track("\(name) - Ignoring volatile memory drop after an app-initiated reset.")
578
+        return true
579
+    }
580
+
581
+    private func didUMVolatileMemoryDecrease(in snapshot: UMSnapshot) -> Bool {
582
+        guard hasSeenUMSnapshot else { return false }
583
+        guard let previousRecord = dataGroupRecords[0], let nextRecord = snapshot.dataGroupRecords[0] else {
584
+            return false
585
+        }
586
+
587
+        return nextRecord.ah < (previousRecord.ah - volatileMemoryDecreaseEpsilon)
588
+            || nextRecord.wh < (previousRecord.wh - volatileMemoryDecreaseEpsilon)
589
+    }
590
+
591
+    private func didUMDeviceReboot(with snapshot: UMSnapshot, at timestamp: Date) -> Bool {
592
+        defer { hasSeenUMSnapshot = true }
593
+
594
+        guard didUMVolatileMemoryDecrease(in: snapshot) else { return false }
595
+        guard !shouldIgnoreVolatileMemoryDrop(at: timestamp) else { return false }
596
+
597
+        track("\(name) - Inferred UM reboot because volatile memory dropped.")
598
+        return true
599
+    }
600
+
601
+    private func didTC66DeviceReboot(with snapshot: TC66Snapshot) -> Bool {
602
+        guard hasSeenTC66Snapshot else { return false }
603
+        guard snapshot.bootCount != bootCount else { return false }
604
+
605
+        track("\(name) - Inferred TC66 reboot because bootCount changed from \(bootCount) to \(snapshot.bootCount).")
606
+        return true
607
+    }
608
+
609
+    private func updateChargerTypeLatch(observedIndex: UInt16, didDetectDeviceReset: Bool) {
610
+        if didDetectDeviceReset, chargerTypeIndex != 0 {
611
+            chargerTypeIndex = 0
612
+        }
613
+
614
+        guard supportsChargerDetection else { return }
615
+
616
+        if chargerTypeIndex == 0 {
617
+            chargerTypeIndex = observedIndex
618
+            return
619
+        }
620
+
621
+        guard observedIndex != 0, observedIndex != chargerTypeIndex else { return }
622
+        track("\(name) - Ignoring charger type change from \(chargerTypeIndex) to \(observedIndex) until the device reboots.")
623
+    }
543 624
     
544 625
     func dataDumpRequest() {
545 626
         guard operationalState >= .peripheralReady else {
@@ -602,6 +683,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
602 683
     }
603 684
 
604 685
     private func apply(umSnapshot snapshot: UMSnapshot) {
686
+        let didDetectDeviceReset = didUMDeviceReboot(with: snapshot, at: dataDumpRequestTimestamp)
605 687
         modelNumber = snapshot.modelNumber
606 688
         voltage = snapshot.voltage
607 689
         current = snapshot.current
@@ -614,7 +696,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
614 696
         }
615 697
         usbPlusVoltage = snapshot.usbPlusVoltage
616 698
         usbMinusVoltage = snapshot.usbMinusVoltage
617
-        chargerTypeIndex = snapshot.chargerTypeIndex
699
+        updateChargerTypeLatch(observedIndex: snapshot.chargerTypeIndex, didDetectDeviceReset: didDetectDeviceReset)
618 700
         recordedAH = snapshot.recordedAH
619 701
         recordedWH = snapshot.recordedWH
620 702
 
@@ -652,6 +734,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
652 734
     }
653 735
 
654 736
     private func apply(tc66Snapshot snapshot: TC66Snapshot) {
737
+        let didDetectDeviceReset = didTC66DeviceReboot(with: snapshot)
655 738
         if hasSeenTC66Snapshot {
656 739
             inferTC66ActiveDataGroup(from: snapshot)
657 740
         } else {
@@ -661,6 +744,7 @@ class Meter : NSObject, ObservableObject, Identifiable {
661 744
         firmwareVersion = snapshot.firmwareVersion
662 745
         serialNumber = snapshot.serialNumber
663 746
         bootCount = snapshot.bootCount
747
+        updateChargerTypeLatch(observedIndex: 0, didDetectDeviceReset: didDetectDeviceReset)
664 748
         voltage = snapshot.voltage
665 749
         current = snapshot.current
666 750
         power = snapshot.power
@@ -774,13 +858,15 @@ class Meter : NSObject, ObservableObject, Identifiable {
774 858
     
775 859
     func clear() {
776 860
         guard supportsDataGroupCommands else { return }
861
+        noteInitiatedVolatileMemoryResetIfNeeded(for: selectedDataGroup)
777 862
         commandQueue.append(UMProtocol.clearCurrentGroup)
778 863
     }
779 864
     
780 865
     func clear(group id: UInt8) {
781 866
         guard supportsDataGroupCommands else { return }
782 867
         commandQueue.append(UMProtocol.selectDataGroup(id))
783
-        clear()
868
+        noteInitiatedVolatileMemoryResetIfNeeded(for: id)
869
+        commandQueue.append(UMProtocol.clearCurrentGroup)
784 870
         commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup))
785 871
     }
786 872
     
+7 -1
USB Meter/Model/MeterCapabilities.swift
@@ -98,6 +98,12 @@ extension MeterCapabilities {
98 98
 }
99 99
 
100 100
 extension Model {
101
+    private static let tc66Tint = Color(
102
+        uiColor: UIColor { traits in
103
+            traits.userInterfaceStyle == .dark ? .systemGray2 : .black
104
+        }
105
+    )
106
+
101 107
     static let byPeripheralName = Dictionary(
102 108
         uniqueKeysWithValues: allCases.flatMap { model in
103 109
             model.peripheralNames.map { ($0, model) }
@@ -131,7 +137,7 @@ extension Model {
131 137
         case .UM34C:
132 138
             return .yellow
133 139
         case .TC66C:
134
-            return .black
140
+            return Self.tc66Tint
135 141
         }
136 142
     }
137 143
 
+106 -109
USB Meter/Views/Meter/LiveView.swift
@@ -15,6 +15,60 @@ struct LiveView: View {
15 15
         let minValue: String
16 16
         let maxValue: String
17 17
     }
18
+
19
+    private struct LoadResistanceSymbol: View {
20
+        let color: Color
21
+
22
+        var body: some View {
23
+            GeometryReader { proxy in
24
+                let width = proxy.size.width
25
+                let height = proxy.size.height
26
+                let midY = height / 2
27
+                let startX = width * 0.10
28
+                let endX = width * 0.90
29
+                let boxMinX = width * 0.28
30
+                let boxMaxX = width * 0.72
31
+                let boxHeight = height * 0.34
32
+                let boxRect = CGRect(
33
+                    x: boxMinX,
34
+                    y: midY - (boxHeight / 2),
35
+                    width: boxMaxX - boxMinX,
36
+                    height: boxHeight
37
+                )
38
+                let strokeWidth = max(1.2, height * 0.055)
39
+
40
+                ZStack {
41
+                    Path { path in
42
+                        path.move(to: CGPoint(x: startX, y: midY))
43
+                        path.addLine(to: CGPoint(x: boxRect.minX, y: midY))
44
+                        path.move(to: CGPoint(x: boxRect.maxX, y: midY))
45
+                        path.addLine(to: CGPoint(x: endX, y: midY))
46
+                    }
47
+                    .stroke(
48
+                        color,
49
+                        style: StrokeStyle(
50
+                            lineWidth: strokeWidth,
51
+                            lineCap: .round,
52
+                            lineJoin: .round
53
+                        )
54
+                    )
55
+
56
+                    Path { path in
57
+                        path.addRect(boxRect)
58
+                    }
59
+                    .stroke(
60
+                        color,
61
+                        style: StrokeStyle(
62
+                            lineWidth: strokeWidth,
63
+                            lineCap: .round,
64
+                            lineJoin: .round
65
+                        )
66
+                    )
67
+                }
68
+            }
69
+            .padding(4)
70
+        }
71
+    }
18 72
     
19 73
     @EnvironmentObject private var meter: Meter
20 74
     var compactLayout: Bool = false
@@ -73,58 +127,28 @@ struct LiveView: View {
73 127
                     value: meter.primaryTemperatureDescription,
74 128
                     range: temperatureRange()
75 129
                 )
76
-            }
77 130
 
78
-            if shouldShowSecondaryDetails {
79
-                Group {
80
-                    if compactLayout {
81
-                        HStack(spacing: 12) {
82
-                            if meter.loadResistance > 0 {
83
-                                secondaryDetailChip(
84
-                                    title: "Load",
85
-                                    value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
86
-                                    symbol: "cable.connector",
87
-                                    color: .yellow
88
-                                )
89
-                            }
90
-
91
-                            if shouldShowChargerType {
92
-                                secondaryDetailChip(
93
-                                    title: "Charger",
94
-                                    value: meter.chargerTypeDescription,
95
-                                    symbol: "bolt.badge.checkmark",
96
-                                    color: .purple
97
-                                )
98
-                            }
99
-                        }
100
-                    } else {
101
-                        VStack(alignment: .leading, spacing: 12) {
102
-                            Text("Details")
103
-                                .font(.subheadline.weight(.semibold))
104
-                                .foregroundColor(.secondary)
105
-
106
-                            if meter.loadResistance > 0 {
107
-                                secondaryDetailRow(
108
-                                    title: "Load",
109
-                                    value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
110
-                                    symbol: "cable.connector",
111
-                                    color: .yellow
112
-                                )
113
-                            }
114
-
115
-                            if shouldShowChargerType {
116
-                                secondaryDetailRow(
117
-                                    title: "Charger",
118
-                                    value: meter.chargerTypeDescription,
119
-                                    symbol: "bolt.badge.checkmark",
120
-                                    color: .purple
121
-                                )
122
-                            }
123
-                        }
124
-                    }
131
+                liveMetricCard(
132
+                    title: "Load",
133
+                    customSymbol: AnyView(LoadResistanceSymbol(color: .yellow)),
134
+                    color: .yellow,
135
+                    value: "\(meter.loadResistance.format(decimalDigits: 1)) \u{2126}",
136
+                    detailText: "Measured resistance"
137
+                )
138
+
139
+                if shouldShowChargerTile {
140
+                    liveMetricCard(
141
+                        title: "Charger",
142
+                        symbol: "bolt.badge.checkmark",
143
+                        color: .purple,
144
+                        value: meter.chargerTypeDescription,
145
+                        detailText: chargerTypeDetailText,
146
+                        valueFont: .system(compactLayout ? .subheadline : .headline, design: .rounded).weight(.bold),
147
+                        valueLineLimit: 2,
148
+                        valueMonospacedDigits: false,
149
+                        valueMinimumScaleFactor: 0.70
150
+                    )
125 151
                 }
126
-                .padding(compactLayout ? 14 : 18)
127
-                .meterCard(tint: meter.color, fillOpacity: 0.06, strokeOpacity: 0.10)
128 152
             }
129 153
         }
130 154
         .frame(maxWidth: .infinity, alignment: .topLeading)
@@ -153,12 +177,12 @@ struct LiveView: View {
153 177
             )
154 178
     }
155 179
 
156
-    private var shouldShowSecondaryDetails: Bool {
157
-        meter.loadResistance > 0 || shouldShowChargerType
180
+    private var shouldShowChargerTile: Bool {
181
+        meter.supportsChargerDetection
158 182
     }
159 183
 
160
-    private var shouldShowChargerType: Bool {
161
-        meter.supportsChargerDetection && meter.chargerTypeDescription != "Unknown"
184
+    private var chargerTypeDetailText: String {
185
+        meter.chargerTypeDescription == "Unknown" ? "No charging profile detected" : "Detected charging profile"
162 186
     }
163 187
 
164 188
     private var usesExpandedCompactLayout: Bool {
@@ -175,19 +199,30 @@ struct LiveView: View {
175 199
 
176 200
     private func liveMetricCard(
177 201
         title: String,
178
-        symbol: String,
202
+        symbol: String? = nil,
203
+        customSymbol: AnyView? = nil,
179 204
         color: Color,
180 205
         value: String,
181 206
         range: MetricRange? = nil,
182
-        detailText: String? = nil
207
+        detailText: String? = nil,
208
+        valueFont: Font? = nil,
209
+        valueLineLimit: Int = 1,
210
+        valueMonospacedDigits: Bool = true,
211
+        valueMinimumScaleFactor: CGFloat = 0.85
183 212
     ) -> some View {
184 213
         VStack(alignment: .leading, spacing: 10) {
185 214
             HStack(spacing: compactLayout ? 8 : 10) {
186
-                Image(systemName: symbol)
187
-                    .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
188
-                    .foregroundColor(color)
215
+                Group {
216
+                    if let customSymbol {
217
+                        customSymbol
218
+                    } else if let symbol {
219
+                        Image(systemName: symbol)
220
+                            .font(.system(size: compactLayout ? 14 : 15, weight: .semibold))
221
+                            .foregroundColor(color)
222
+                    }
223
+                }
189 224
                     .frame(width: compactLayout ? 30 : 34, height: compactLayout ? 30 : 34)
190
-                    .background(Circle().fill(color.opacity(0.12)))
225
+                .background(Circle().fill(color.opacity(0.12)))
191 226
 
192 227
                 Text(title)
193 228
                     .font((compactLayout ? Font.caption : .subheadline).weight(.semibold))
@@ -197,9 +232,17 @@ struct LiveView: View {
197 232
                 Spacer(minLength: 0)
198 233
             }
199 234
 
200
-            Text(value)
201
-                .font(.system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
202
-                .monospacedDigit()
235
+            Group {
236
+                if valueMonospacedDigits {
237
+                    Text(value)
238
+                        .monospacedDigit()
239
+                } else {
240
+                    Text(value)
241
+                }
242
+            }
243
+            .font(valueFont ?? .system(compactLayout ? .headline : .title3, design: .rounded).weight(.bold))
244
+            .lineLimit(valueLineLimit)
245
+            .minimumScaleFactor(valueMinimumScaleFactor)
203 246
 
204 247
             if shouldShowMetricRange {
205 248
                 if let range {
@@ -221,52 +264,6 @@ struct LiveView: View {
221 264
         .meterCard(tint: color, fillOpacity: 0.10, strokeOpacity: 0.12)
222 265
     }
223 266
 
224
-    private func secondaryDetailRow(
225
-        title: String,
226
-        value: String,
227
-        symbol: String,
228
-        color: Color
229
-    ) -> some View {
230
-        HStack(spacing: 12) {
231
-            Image(systemName: symbol)
232
-                .foregroundColor(color)
233
-                .frame(width: 28)
234
-            Text(title)
235
-                .foregroundColor(.secondary)
236
-            Spacer()
237
-            Text(value)
238
-                .fontWeight(.semibold)
239
-                .multilineTextAlignment(.trailing)
240
-        }
241
-        .font(.footnote)
242
-    }
243
-
244
-    private func secondaryDetailChip(
245
-        title: String,
246
-        value: String,
247
-        symbol: String,
248
-        color: Color
249
-    ) -> some View {
250
-        HStack(spacing: 10) {
251
-            Image(systemName: symbol)
252
-                .foregroundColor(color)
253
-                .frame(width: 22, height: 22)
254
-                .background(Circle().fill(color.opacity(0.12)))
255
-
256
-            VStack(alignment: .leading, spacing: 2) {
257
-                Text(title)
258
-                    .foregroundColor(.secondary)
259
-                Text(value)
260
-                    .fontWeight(.semibold)
261
-                    .lineLimit(1)
262
-            }
263
-
264
-            Spacer(minLength: 0)
265
-        }
266
-        .font(.caption)
267
-        .frame(maxWidth: .infinity, alignment: .leading)
268
-    }
269
-
270 267
     private func metricRangeTable(_ range: MetricRange) -> some View {
271 268
         VStack(alignment: .leading, spacing: 4) {
272 269
             HStack(spacing: 12) {