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