From 6bcdb2e5057807558d3bc649434cf2509ea43d3e Mon Sep 17 00:00:00 2001 From: Jake-B Date: Mon, 23 Dec 2024 17:03:41 -0500 Subject: [PATCH 1/8] Initial implementation of additional Environment Metrics fields --- Meshtastic.xcodeproj/project.pbxproj | 4 +- Meshtastic/Export/WriteCsvFile.swift | 18 +- Meshtastic/Helpers/MeshPackets.swift | 5 + .../Meshtastic.xcdatamodeld/.xccurrentversion | 2 +- .../contents | 491 ++++++++++++++++++ .../EnviornmentDefaultSeries.swift | 163 +++++- .../EnvironmentDefaultColumns.swift | 89 +++- 7 files changed, 767 insertions(+), 5 deletions(-) create mode 100644 Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 8877bfa99..0b6e04183 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -270,6 +270,7 @@ 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; + 237013332D1A0D4F007DBE7F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = ""; }; @@ -1965,6 +1966,7 @@ DD3CC6BA28E366DF00FA9159 /* Meshtastic.xcdatamodeld */ = { isa = XCVersionGroup; children = ( + 237013332D1A0D4F007DBE7F /* MeshtasticDataModelV 48.xcdatamodel */, DDDFE7402D0D4A070044463C /* MeshtasticDataModelV 47.xcdatamodel */, DD0BE30C2CB785D8000BA445 /* MeshtasticDataModelV 46.xcdatamodel */, DD6D5A342CA13BA600ED3032 /* MeshtasticDataModelV 45.xcdatamodel */, @@ -2013,7 +2015,7 @@ DD5D0A9A2931AD6B00F7EA61 /* MeshtasticDataModelV2.xcdatamodel */, DD3CC6BB28E366DF00FA9159 /* MeshtasticDataModel.xcdatamodel */, ); - currentVersion = DDDFE7402D0D4A070044463C /* MeshtasticDataModelV 47.xcdatamodel */; + currentVersion = 237013332D1A0D4F007DBE7F /* MeshtasticDataModelV 48.xcdatamodel */; name = Meshtastic.xcdatamodeld; path = Meshtastic/Meshtastic.xcdatamodeld; sourceTree = ""; diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index 56776554e..3bd98f0a0 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -31,7 +31,7 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin } } else if metricsType == 1 { // Create Environment Telemetry Header - csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, \("timestamp".localized)" + csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, Wind Direction, Wind Speed, Distance, Lux, White Lux, UV Lux, IR Lux, Radiation, \("timestamp".localized)" for dm in telemetry where dm.metricsType == 1 { csvString += "\n" csvString += String(dm.temperature.localeTemperature()) @@ -44,6 +44,22 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin csvString += ", " csvString += String(dm.gasResistance) csvString += ", " + csvString += String(dm.windDirection) + csvString += ", " + csvString += String(dm.windSpeed) + csvString += ", " + csvString += String(dm.distance) + csvString += ", " + csvString += String(dm.lux) + csvString += ", " + csvString += String(dm.whiteLux) + csvString += ", " + csvString += String(dm.uvLux) + csvString += ", " + csvString += String(dm.irLux) + csvString += ", " + csvString += String(dm.radiation) + csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index fd9ffb8ef..fd2a9f3de 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -710,15 +710,20 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage telemetry.current = telemetryMessage.environmentMetrics.current telemetry.iaq = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq) telemetry.gasResistance = telemetryMessage.environmentMetrics.gasResistance + telemetry.irLux = telemetryMessage.environmentMetrics.irLux + telemetry.lux = telemetryMessage.environmentMetrics.lux telemetry.relativeHumidity = telemetryMessage.environmentMetrics.relativeHumidity telemetry.temperature = telemetryMessage.environmentMetrics.temperature telemetry.current = telemetryMessage.environmentMetrics.current + telemetry.distance = telemetryMessage.environmentMetrics.distance + telemetry.uvLux = telemetryMessage.environmentMetrics.uvLux telemetry.voltage = telemetryMessage.environmentMetrics.voltage telemetry.weight = telemetryMessage.environmentMetrics.weight telemetry.windSpeed = telemetryMessage.environmentMetrics.windSpeed telemetry.windGust = telemetryMessage.environmentMetrics.windGust telemetry.windLull = telemetryMessage.environmentMetrics.windLull telemetry.windDirection = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) + telemetry.radiation = telemetryMessage.environmentMetrics.radiation telemetry.metricsType = 1 } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { // Local Stats for Live activity diff --git a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion index 3581f63f6..a702965e5 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion +++ b/Meshtastic/Meshtastic.xcdatamodeld/.xccurrentversion @@ -3,6 +3,6 @@ _XCCurrentVersionName - MeshtasticDataModelV 47.xcdatamodel + MeshtasticDataModelV 48.xcdatamodel diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents new file mode 100644 index 000000000..3fd0d311f --- /dev/null +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents @@ -0,0 +1,491 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift index 810eaab77..99d638f41 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -97,6 +97,52 @@ extension MetricsSeriesList { }), + // Barometric Pressure Series Configuration + MetricsChartSeries( + keyPath: \.distance, + name: "Distance", + abbreviatedName: "Dist", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.cyan.darker(componentDelta: 0.3)), .cyan], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, distance in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, distance) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // Gas Resistance + MetricsChartSeries( + keyPath: \.gasResistance, + name: "Gas Resistance", + abbreviatedName: "Gas Res", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.brown.darker(componentDelta: 0.3)), .brown], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, resistance in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, resistance) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + // Indoor Air Quality Series Configuration MetricsChartSeries( keyPath: \.iaq, @@ -122,6 +168,98 @@ extension MetricsSeriesList { .alignsMarkStylesWithPlotArea() }), + // Lux + MetricsChartSeries( + keyPath: \.lux, + name: "Lux", + abbreviatedName: "Lux", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.blue.lighter(componentDelta: 0.3)), .blue], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, lux in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, lux) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // White Lux + MetricsChartSeries( + keyPath: \.whiteLux, + name: "White Lux", + abbreviatedName: "White", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.blue.lighter(componentDelta: 0.5)), Color(UIColor.blue.lighter(componentDelta: 0.2))], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, lux in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, lux) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // UV Lux + MetricsChartSeries( + keyPath: \.uvLux, + name: "UV Lux", + abbreviatedName: "UV", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.systemIndigo.lighter(componentDelta: 0.5)), Color(UIColor.systemIndigo.lighter(componentDelta: 0.2))], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, lux in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, lux) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + + // IR Lux + MetricsChartSeries( + keyPath: \.irLux, + name: "IR Lux", + abbreviatedName: "IR", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.red.darker(componentDelta: 0.5)), .red], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, lux in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, lux) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), + // Combined Wind Speed and Direction Series Configuration -- For use in Chart only MetricsChartSeries( keyPath: \.windSpeedAndDirection, @@ -155,7 +293,30 @@ extension MetricsSeriesList { .rotationEffect( .degrees(Double(wsad.windDirection))) }.foregroundStyle(.yellow) - }) + }), + + // Radiation + MetricsChartSeries( + keyPath: \.radiation, + name: "Radiation", + abbreviatedName: "☢️", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.orange.darker(componentDelta: 0.5)), .orange], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, radiation in + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, radiation) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + }), ]) } } diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index 7df0dbd3f..59d9c650b 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -46,7 +46,37 @@ extension MetricsColumnList { Text("\(String(format: "%.1f", pressure))") } }), - + + // Distance sensor, often used for water level + MetricsTableColumn( + keyPath: \.distance, + name: "Distance", + abbreviatedName: "Dist", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, distance in + if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { + Text("\(String(format: "%.1f mm", distance))") + } else { + Text("\(String(format: "%.1f", distance))") + } + }), + + // Gas Resistance + MetricsTableColumn( + keyPath: \.gasResistance, + name: "Gas Resistance", + abbreviatedName: "Gas Res", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, resistance in + if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { + Text("\(String(format: "%.1f MΩ", resistance))") + } else { + Text("\(String(format: "%.1f", resistance))") + } + }), + // Indoor Air Quality Series Configuration MetricsTableColumn( keyPath: \.iaq, @@ -57,6 +87,63 @@ extension MetricsColumnList { IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) }), + // Various Lux + MetricsTableColumn( + keyPath: \.lux, + name: "Lux", + abbreviatedName: "Lux", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, lux in + Text("\(String(format: "%.1f", lux))") + }), + + MetricsTableColumn( + keyPath: \.whiteLux, + name: "White Lux", + abbreviatedName: "White", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, lux in + Text("\(String(format: "%.1f", lux))") + }), + + MetricsTableColumn( + keyPath: \.uvLux, + name: "UV Lux", + abbreviatedName: "UV", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, lux in + Text("\(String(format: "%.1f", lux))") + }), + + MetricsTableColumn( + keyPath: \.irLux, + name: "IR Lux", + abbreviatedName: "IR", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, lux in + Text("\(String(format: "%.1f", lux))") + }), + + // Radiation + MetricsTableColumn( + keyPath: \.radiation, + name: "Radiation", + abbreviatedName: "☢️", + minWidth: 30, maxWidth: 50, + visible: false, + tableBody: { _, radiation in + if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { + Text("\(String(format: "%.1f µR/h", radiation))") + } else { + Text("\(String(format: "%.1f", radiation))") + } + + }), + // Wind Direction Series Configuration MetricsTableColumn( keyPath: \.windDirection, From f6034b627f52b10b2cb516bbac154970d8c14097 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Wed, 25 Dec 2024 09:06:04 -0500 Subject: [PATCH 2/8] Removed Gas Resistance from column/series list --- .../EnviornmentDefaultSeries.swift | 48 +++++++++--------- .../EnvironmentDefaultColumns.swift | 49 ++++++++++--------- 2 files changed, 50 insertions(+), 47 deletions(-) diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift index 99d638f41..119f3f177 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -97,7 +97,7 @@ extension MetricsSeriesList { }), - // Barometric Pressure Series Configuration + // Distance sensor, often used for water level MetricsChartSeries( keyPath: \.distance, name: "Distance", @@ -120,28 +120,30 @@ extension MetricsSeriesList { .alignsMarkStylesWithPlotArea() }), - // Gas Resistance - MetricsChartSeries( - keyPath: \.gasResistance, - name: "Gas Resistance", - abbreviatedName: "Gas Res", - visible: false, - foregroundStyle: { _ in - .linearGradient( - colors: [Color(UIColor.brown.darker(componentDelta: 0.3)), .brown], - startPoint: .bottom, endPoint: .top - ) - }, - chartBody: { series, _, time, resistance in - LineMark( - x: .value("Time", time), - y: .value(series.abbreviatedName, resistance) - ) - .interpolationMethod(.catmullRom) - .foregroundStyle(by: .value("Series", series.abbreviatedName)) - .lineStyle(StrokeStyle(lineWidth: 4)) - .alignsMarkStylesWithPlotArea() - }), + +// // Gas Resistance - This is a raw sensor value used for IAQ. +// // Commented out as better represented in the IAQ value. +// MetricsChartSeries( +// keyPath: \.gasResistance, +// name: "Gas Resistance", +// abbreviatedName: "Gas Res", +// visible: false, +// foregroundStyle: { _ in +// .linearGradient( +// colors: [Color(UIColor.brown.darker(componentDelta: 0.3)), .brown], +// startPoint: .bottom, endPoint: .top +// ) +// }, +// chartBody: { series, _, time, resistance in +// LineMark( +// x: .value("Time", time), +// y: .value(series.abbreviatedName, resistance) +// ) +// .interpolationMethod(.catmullRom) +// .foregroundStyle(by: .value("Series", series.abbreviatedName)) +// .lineStyle(StrokeStyle(lineWidth: 4)) +// .alignsMarkStylesWithPlotArea() +// }), // Indoor Air Quality Series Configuration MetricsChartSeries( diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index 59d9c650b..64aaef091 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -62,30 +62,31 @@ extension MetricsColumnList { } }), - // Gas Resistance - MetricsTableColumn( - keyPath: \.gasResistance, - name: "Gas Resistance", - abbreviatedName: "Gas Res", - minWidth: 30, maxWidth: 50, - visible: false, - tableBody: { _, resistance in - if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { - Text("\(String(format: "%.1f MΩ", resistance))") - } else { - Text("\(String(format: "%.1f", resistance))") - } - }), - - // Indoor Air Quality Series Configuration - MetricsTableColumn( - keyPath: \.iaq, - name: "Indoor Air Quality", - abbreviatedName: "IAQ", - minWidth: 30, maxWidth: 50, - tableBody: { _, iaq in - IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) - }), +// // Gas Resistance - This is a raw sensor value used for IAQ. +// // Commented out as better represented in the IAQ value. +// MetricsTableColumn( +// keyPath: \.gasResistance, +// name: "Gas Resistance", +// abbreviatedName: "Gas Res", +// minWidth: 30, maxWidth: 50, +// visible: false, +// tableBody: { _, resistance in +// if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) { +// Text("\(String(format: "%.1f MΩ", resistance))") +// } else { +// Text("\(String(format: "%.1f", resistance))") +// } +// }), +// +// // Indoor Air Quality Series Configuration +// MetricsTableColumn( +// keyPath: \.iaq, +// name: "Indoor Air Quality", +// abbreviatedName: "IAQ", +// minWidth: 30, maxWidth: 50, +// tableBody: { _, iaq in +// IndoorAirQuality(iaq: Int(iaq), displayMode: .dot) +// }), // Various Lux MetricsTableColumn( From 1563b14c60ae497b2d9fca7155ead5e669c1396d Mon Sep 17 00:00:00 2001 From: Jake-B Date: Wed, 25 Dec 2024 11:43:59 -0500 Subject: [PATCH 3/8] Additional Y-axis scaling options --- .../MetricsChartSeries.swift | 7 ++ .../MetricsSeriesList.swift | 78 +++++++++++++++++-- .../EnviornmentDefaultSeries.swift | 2 + 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index add0318e7..291df388e 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -37,11 +37,16 @@ class MetricsChartSeries: ObservableObject { // Possibly converted to the proper units let valueClosure: (TelemetryEntity) -> Float? + let initialYAxisRange: ClosedRange? + let minumumYAxisSpan: Float? + // Main initializer init( keyPath: KeyPath, name: String, abbreviatedName: String, + initialYAxisRange: ClosedRange? = nil, + minumumYAxisSpan: Float? = nil, conversion: ((Value) -> Value)? = nil, visible: Bool = true, foregroundStyle: @escaping ((ClosedRange?) -> ForegroundStyle?) = { _ in nil }, @@ -52,6 +57,8 @@ class MetricsChartSeries: ObservableObject { self.attribute = NSExpression(forKeyPath: keyPath).keyPath self.name = name self.abbreviatedName = abbreviatedName + self.initialYAxisRange = initialYAxisRange + self.minumumYAxisSpan = minumumYAxisSpan self.visible = visible // By saving these closures, MetricsChartSeries can be type agnostic diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift index 049d1fb46..347109e6a 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -7,6 +7,8 @@ import Foundation import SwiftUI +import OSLog + class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection { @Published var series: [MetricsChartSeries] @@ -38,23 +40,83 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea return nil } + // Calculates the chartRange based on the series configuration and data provided + // Besides checkign the range of the data, this function also obeys some series-level + // configuraiton, such as: + // 1. starting with a desired fixed range + // 2. obeying a minimum span func chartRange(forData data: [TelemetryEntity]) -> ClosedRange { - var lower: Float? - var upper: Float? + var globalLower: Float = .infinity + var globalUpper: Float = -.infinity + + // Keep track of the range of each series + var range: [MetricsChartSeries: ClosedRange] = [:] + + // Determine if there is an initial fixed range. + // The range might exapand past this initial range if the data goes beyond. + for aSeries in self.visible { + if let thisRange = aSeries.initialYAxisRange { + range[aSeries] = thisRange + if thisRange.upperBound > globalUpper {globalUpper = thisRange.upperBound} + if thisRange.lowerBound < globalLower {globalLower = thisRange.lowerBound} + } + } + + // Iterate through all the data. It would be easier to iterate + // the series then the data, but this way we only iterate the data once for te in data { for aSeries in self.visible { + var seriesUpper = range[aSeries]?.upperBound ?? -.infinity + var seriesLower = range[aSeries]?.lowerBound ?? .infinity + if let value = aSeries.valueFor(te) { - if value > (upper ?? -.infinity) {upper = value} - if value < (lower ?? .infinity) {lower = value} + // Update the global bounds + if value > globalUpper {globalUpper = value} + if value < globalLower {globalLower = value} + + // Update the series bounds if necessary + if value > seriesUpper || value < seriesLower { + if value > seriesUpper { + seriesUpper = value + } + if value < seriesLower { + seriesLower = value + } + if seriesUpper.isFinite && seriesLower.isFinite { + range[aSeries] = seriesLower...seriesUpper + } + } } } } - - // Return default range if no data or nil - guard let lower, let upper else { + + // Go through each series one last time to obey the minimum span + for aSeries in self.visible { + if let minimumSpan = aSeries.minumumYAxisSpan, + let currentRange = range[aSeries] { + let currentSpan = currentRange.upperBound - currentRange.lowerBound + Logger.data.info("Updated \(aSeries.attribute) to \(range[aSeries] ?? 0...0) span=\(currentSpan)") + if currentSpan < minimumSpan { + // Calculate the center of the range + let centerOfRange = currentRange.lowerBound + (currentSpan / 2) + let newLower = centerOfRange - (minimumSpan / 2.0) + let newUpper = centerOfRange + (minimumSpan / 2.0) + + if newUpper > globalUpper { + globalUpper = newUpper + } + if newLower < globalLower { + globalLower = newLower + } + } + } + } + + // Return default range if no data + if !globalLower.isFinite || !globalUpper.isFinite { return 0.0...100.0 } - return lower...upper + return globalLower...globalUpper } // Collection conformance diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift index 119f3f177..585939cff 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -18,6 +18,7 @@ extension MetricsSeriesList { keyPath: \.temperature, name: "Temperature", abbreviatedName: "Temp", + minumumYAxisSpan: 50.0, conversion: { Float($0.localeTemperature()) }, foregroundStyle: { chartRange in let locale = NSLocale.current as NSLocale @@ -56,6 +57,7 @@ extension MetricsSeriesList { keyPath: \.relativeHumidity, name: "Relative Humidity", abbreviatedName: "Hum", + initialYAxisRange: 0.0...100.0, foregroundStyle: { _ in .linearGradient( colors: [Color(UIColor.purple.darker(componentDelta: 0.2)), .purple], From 74201502e76dbd99c8b663eec494201fe9f662c1 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Thu, 26 Dec 2024 08:07:13 -0500 Subject: [PATCH 4/8] Initial persistence for columns/chart series --- .../contents | 2 + .../MetricTableColumn.swift | 13 ++--- .../MetricsChartSeries.swift | 13 ++--- .../MetricsColumnList.swift | 17 ++++++- .../MetricsSeriesList.swift | 15 +++++- .../Views/Nodes/EnvironmentMetricsLog.swift | 49 ++++++++++++++----- .../EnviornmentDefaultSeries.swift | 12 +++++ .../EnvironmentDefaultColumns.swift | 13 +++++ .../Metrics Columns/MetricsColumnDetail.swift | 9 +++- 9 files changed, 116 insertions(+), 27 deletions(-) diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents index 3fd0d311f..55ba48a50 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents @@ -232,6 +232,8 @@ + + diff --git a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift index 188e4eba7..d27f3e796 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift @@ -13,8 +13,9 @@ import SwiftUI // Given a keypath, this class holds information about how to render the attrbute in // the table. MetricsTableColumn objects are collected in a MetricsColumnList class MetricsTableColumn: ObservableObject { - // CoreData Attribute Name on TelemetryEntity - let attribute: String + // Uniquely identify this column for presistance and iteration + // Recommend using CoreData Attribute Name on TelemetryEntity + let id: String // Heading for wider tables let name: String @@ -37,6 +38,7 @@ class MetricsTableColumn: ObservableObject { // Main initializer init( + id: String, keyPath: KeyPath, name: String, abbreviatedName: String, @@ -47,7 +49,7 @@ class MetricsTableColumn: ObservableObject { @ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent? ) { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject - self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.id = id self.name = name self.abbreviatedName = abbreviatedName self.minWidth = minWidth @@ -72,13 +74,12 @@ class MetricsTableColumn: ObservableObject { } extension MetricsTableColumn: Identifiable, Hashable { - var id: String { self.attribute } static func == (lhs: MetricsTableColumn, rhs: MetricsTableColumn) -> Bool { - lhs.attribute == rhs.attribute + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(attribute) + hasher.combine(id) } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index 291df388e..fbff2fb07 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -14,8 +14,9 @@ import SwiftUI // the chart. MetricsChartSeries objects are collected in a MetricsSeriesList class MetricsChartSeries: ObservableObject { - // CoreData Attribute Name on TelemetryEntity - let attribute: String + // Uniquely identify this column for presistance and iteration + // Recommend using CoreData Attribute Name on TelemetryEntity + let id: String // Heading for areas that have the room let name: String @@ -42,6 +43,7 @@ class MetricsChartSeries: ObservableObject { // Main initializer init( + id: String, keyPath: KeyPath, name: String, abbreviatedName: String, @@ -54,7 +56,7 @@ class MetricsChartSeries: ObservableObject { ) where Value: Plottable & Comparable { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject - self.attribute = NSExpression(forKeyPath: keyPath).keyPath + self.id = id self.name = name self.abbreviatedName = abbreviatedName self.initialYAxisRange = initialYAxisRange @@ -89,14 +91,13 @@ class MetricsChartSeries: ObservableObject { } extension MetricsChartSeries: Identifiable, Hashable { - var id: String { self.attribute } static func == (lhs: MetricsChartSeries, rhs: MetricsChartSeries) -> Bool { - lhs.attribute == rhs.attribute + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(attribute) + hasher.combine(id) } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift index 0476b6b89..2e6f7a148 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -43,10 +43,23 @@ class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplacea return returnValues } - func column(forAttribute attribute: String) -> MetricsTableColumn? { - return columns.first(where: { $0.attribute == attribute}) + func column(withId id: String) -> MetricsTableColumn? { + return columns.first(where: { $0.id == id}) } + func applyDefaults(forNode node: NodeInfoEntity) { + if let columnList = node.telemetryColumns { + for aColumn in self { + aColumn.visible = columnList.contains(aColumn.id) + } + } + } + + func saveDefaults(forNode node: NodeInfoEntity) { + let columns = self.visible.map( { $0.id } ) + node.telemetryColumns = columns + } + // Collection conformance typealias Index = Int typealias Element = MetricsTableColumn diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift index 347109e6a..0dbc00bc0 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -95,7 +95,7 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea if let minimumSpan = aSeries.minumumYAxisSpan, let currentRange = range[aSeries] { let currentSpan = currentRange.upperBound - currentRange.lowerBound - Logger.data.info("Updated \(aSeries.attribute) to \(range[aSeries] ?? 0...0) span=\(currentSpan)") + Logger.data.info("Updated \(aSeries.id) to \(range[aSeries] ?? 0...0) span=\(currentSpan)") if currentSpan < minimumSpan { // Calculate the center of the range let centerOfRange = currentRange.lowerBound + (currentSpan / 2) @@ -119,6 +119,19 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea return globalLower...globalUpper } + func applyDefaults(forNode node: NodeInfoEntity) { + if let seriesList = node.telemetrySeries { + for aSeries in self { + aSeries.visible = seriesList.contains(aSeries.id) + } + } + } + + func saveDefaults(forNode node: NodeInfoEntity) { + let series = self.visible.map( { $0.id } ) + node.telemetrySeries = series + } + // Collection conformance typealias Index = Int typealias Element = MetricsChartSeries diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index b7d5449c8..3032afafb 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -17,8 +17,24 @@ struct EnvironmentMetricsLog: View { @State var exportString = "" @ObservedObject var node: NodeInfoEntity - @StateObject var columnList = MetricsColumnList.environmentDefaultColumns - @StateObject var seriesList = MetricsSeriesList.environmentDefaultChartSeries + @StateObject var columnList: MetricsColumnList + @StateObject var seriesList: MetricsSeriesList + + init(node: NodeInfoEntity) { + self.node = node + + // Load the hard-coded column definitons and then apply the + // user's node-level visible column prefrences + let defaultColumns = MetricsColumnList.environmentDefaultColumns + defaultColumns.applyDefaults(forNode: node) + self._columnList = StateObject(wrappedValue: defaultColumns) + + // Load the hard-coded series definitons and then apply the + // user's node-level visible series prefrences + let defaultSeries = MetricsSeriesList.environmentDefaultChartSeries + defaultSeries.applyDefaults(forNode: node) + self._seriesList = StateObject(wrappedValue: defaultSeries) + } @State var isEditingColumnConfiguration = false @@ -56,25 +72,25 @@ struct EnvironmentMetricsLog: View { // Add a table for mac and ipad Table(environmentMetrics) { TableColumn("Temperature") { em in - columnList.column(forAttribute: "temperature")?.body(em) + columnList.column(withId: "temperature")?.body(em) } TableColumn("Humidity") { em in - columnList.column(forAttribute: "relativeHumidity")?.body(em) + columnList.column(withId: "relativeHumidity")?.body(em) } TableColumn("Barometric Pressure") { em in - columnList.column(forAttribute: "barometricPressure")?.body(em) + columnList.column(withId: "barometricPressure")?.body(em) } TableColumn("Indoor Air Quality") { em in - columnList.column(forAttribute: "iaq")?.body(em) + columnList.column(withId: "iaq")?.body(em) } TableColumn("Wind Speed") { em in - columnList.column(forAttribute: "windSpeed")?.body(em) + columnList.column(withId: "windSpeed")?.body(em) } TableColumn("Wind Direction") { em in - columnList.column(forAttribute: "windDirection")?.body(em) + columnList.column(withId: "windDirection")?.body(em) } TableColumn("timestamp") { em in - columnList.column(forAttribute: "time")?.body(em) + columnList.column(withId: "time")?.body(em) } .width(min: 180) } @@ -117,8 +133,8 @@ struct EnvironmentMetricsLog: View { .controlSize(buttonSize) .padding(.bottom) .padding(.leading) - .sheet(isPresented: self.$isEditingColumnConfiguration) { - MetricsColumnDetail(columnList: columnList, seriesList: seriesList) + .sheet(isPresented: self.$isEditingColumnConfiguration, onDismiss: didDismissConfigSheet) { + MetricsColumnDetail(columnList: columnList, seriesList: seriesList, forNode: node) } Button(role: .destructive) { isPresentingClearLogConfirm = true @@ -183,6 +199,17 @@ struct EnvironmentMetricsLog: View { ) } + func didDismissConfigSheet() { + do { + self.seriesList.saveDefaults(forNode: node) + self.columnList.saveDefaults(forNode: node) + try context.save() + } catch { + let nsError = error as NSError + Logger.data.error("🚫 Error deleting saving node preferences from core data: \(nsError)") + } + } + // Helper. Adds a little buffer to the Y axis range, but keeps Y=0 func applyMargins(_ range: ClosedRange) -> ClosedRange where T: BinaryFloatingPoint { let span = range.upperBound - range.lowerBound diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift index 585939cff..af4d0603f 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -15,6 +15,7 @@ extension MetricsSeriesList { MetricsSeriesList([ // Temperature Series Configuration MetricsChartSeries( + id: "temperature", keyPath: \.temperature, name: "Temperature", abbreviatedName: "Temp", @@ -54,6 +55,7 @@ extension MetricsSeriesList { // Relative Humidity Series Configuration MetricsChartSeries( + id: "relativeHumidity", keyPath: \.relativeHumidity, name: "Relative Humidity", abbreviatedName: "Hum", @@ -77,6 +79,7 @@ extension MetricsSeriesList { // Barometric Pressure Series Configuration MetricsChartSeries( + id: "barometricPressure", keyPath: \.barometricPressure, name: "Barometric Pressure", abbreviatedName: "Bar", @@ -101,6 +104,7 @@ extension MetricsSeriesList { // Distance sensor, often used for water level MetricsChartSeries( + id: "distance", keyPath: \.distance, name: "Distance", abbreviatedName: "Dist", @@ -126,6 +130,7 @@ extension MetricsSeriesList { // // Gas Resistance - This is a raw sensor value used for IAQ. // // Commented out as better represented in the IAQ value. // MetricsChartSeries( +// id: "gasResistance" // keyPath: \.gasResistance, // name: "Gas Resistance", // abbreviatedName: "Gas Res", @@ -149,6 +154,7 @@ extension MetricsSeriesList { // Indoor Air Quality Series Configuration MetricsChartSeries( + id: "iaq", keyPath: \.iaq, name: "Indoor Air Quality", abbreviatedName: "IAQ", @@ -174,6 +180,7 @@ extension MetricsSeriesList { // Lux MetricsChartSeries( + id: "lux", keyPath: \.lux, name: "Lux", abbreviatedName: "Lux", @@ -197,6 +204,7 @@ extension MetricsSeriesList { // White Lux MetricsChartSeries( + id: "whiteLux", keyPath: \.whiteLux, name: "White Lux", abbreviatedName: "White", @@ -220,6 +228,7 @@ extension MetricsSeriesList { // UV Lux MetricsChartSeries( + id: "uvLux", keyPath: \.uvLux, name: "UV Lux", abbreviatedName: "UV", @@ -243,6 +252,7 @@ extension MetricsSeriesList { // IR Lux MetricsChartSeries( + id: "irLux", keyPath: \.irLux, name: "IR Lux", abbreviatedName: "IR", @@ -266,6 +276,7 @@ extension MetricsSeriesList { // Combined Wind Speed and Direction Series Configuration -- For use in Chart only MetricsChartSeries( + id: "windSpeedAndDirection", keyPath: \.windSpeedAndDirection, name: "Wind Speed/Direction", abbreviatedName: "Speed/Dir", @@ -301,6 +312,7 @@ extension MetricsSeriesList { // Radiation MetricsChartSeries( + id: "radiation", keyPath: \.radiation, name: "Radiation", abbreviatedName: "☢️", diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index 64aaef091..da0708820 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -15,6 +15,7 @@ extension MetricsColumnList { MetricsColumnList(columns: [ // Temperature Series Configuration MetricsTableColumn( + id: "temperature", keyPath: \.temperature, name: "Temperature", abbreviatedName: "Temp", @@ -25,6 +26,7 @@ extension MetricsColumnList { // Relative Humidity Series Configuration MetricsTableColumn( + id: "relativeHumidity", keyPath: \.relativeHumidity, name: "Relative Humidity", abbreviatedName: "Hum", @@ -35,6 +37,7 @@ extension MetricsColumnList { // Barometric Pressure Series Configuration MetricsTableColumn( + id: "barometricPressure", keyPath: \.barometricPressure, name: "Barometric Pressure", abbreviatedName: "Bar", @@ -49,6 +52,7 @@ extension MetricsColumnList { // Distance sensor, often used for water level MetricsTableColumn( + id: "distance", keyPath: \.distance, name: "Distance", abbreviatedName: "Dist", @@ -65,6 +69,7 @@ extension MetricsColumnList { // // Gas Resistance - This is a raw sensor value used for IAQ. // // Commented out as better represented in the IAQ value. // MetricsTableColumn( +// id: "gasResistance", // keyPath: \.gasResistance, // name: "Gas Resistance", // abbreviatedName: "Gas Res", @@ -90,6 +95,7 @@ extension MetricsColumnList { // Various Lux MetricsTableColumn( + id: "lux", keyPath: \.lux, name: "Lux", abbreviatedName: "Lux", @@ -100,6 +106,7 @@ extension MetricsColumnList { }), MetricsTableColumn( + id: "whiteLux", keyPath: \.whiteLux, name: "White Lux", abbreviatedName: "White", @@ -110,6 +117,7 @@ extension MetricsColumnList { }), MetricsTableColumn( + id: "uvLux", keyPath: \.uvLux, name: "UV Lux", abbreviatedName: "UV", @@ -120,6 +128,7 @@ extension MetricsColumnList { }), MetricsTableColumn( + id: "irLux", keyPath: \.irLux, name: "IR Lux", abbreviatedName: "IR", @@ -131,6 +140,7 @@ extension MetricsColumnList { // Radiation MetricsTableColumn( + id: "radiation", keyPath: \.radiation, name: "Radiation", abbreviatedName: "☢️", @@ -147,6 +157,7 @@ extension MetricsColumnList { // Wind Direction Series Configuration MetricsTableColumn( + id: "windDirection", keyPath: \.windDirection, name: "Wind Direction", abbreviatedName: "Dir", @@ -171,6 +182,7 @@ extension MetricsColumnList { // Wind Speed Series Configuration MetricsTableColumn( + id: "windSpeed", keyPath: \.windSpeed, name: "Wind Speed", abbreviatedName: "Wind", @@ -190,6 +202,7 @@ extension MetricsColumnList { // Timestamp Series Configuration -- for use in table only MetricsTableColumn( + id: "time", keyPath: \.time, name: "Timestamp", abbreviatedName: "Time", diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift index 1f384cb24..1823c2dad 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -10,7 +10,14 @@ import SwiftUI struct MetricsColumnDetail: View { @ObservedObject var columnList: MetricsColumnList @ObservedObject var seriesList: MetricsSeriesList - + let node: NodeInfoEntity + + init(columnList: MetricsColumnList, seriesList: MetricsSeriesList, forNode node: NodeInfoEntity) { + self.columnList = columnList + self.seriesList = seriesList + self.node = node + } + @State private var currentDetent = PresentationDetent.medium @Environment(\.dismiss) private var dismiss From 80565043c45146c457912dfb39047c6b3f92fb09 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Thu, 26 Dec 2024 13:50:38 -0500 Subject: [PATCH 5/8] Nil handling for Telemetry + other enhancements --- Localizable.xcstrings | 26 ++ Meshtastic.xcodeproj/project.pbxproj | 20 ++ Meshtastic/Export/WriteCsvFile.swift | 36 +-- .../ManagedAttributePropertyWrapper.swift | 61 ++++ Meshtastic/Helpers/MeshPackets.swift | 51 ++-- .../contents | 2 +- .../TelemetryEntity+CoreDataClass.swift | 40 +++ .../TelemetryEntity+CoreDataProperties.swift | 30 ++ .../MetricsChartSeries.swift | 14 +- .../MetricsSeriesList.swift | 4 +- Meshtastic/Views/Helpers/BatteryCompact.swift | 95 +++--- .../Weather/LocalWeatherConditions.swift | 82 +++++- Meshtastic/Views/Nodes/DeviceMetricsLog.swift | 97 +++--- .../EnviornmentDefaultSeries.swift | 278 ++++++++++-------- .../EnvironmentDefaultColumns.swift | 109 ++++--- .../Views/Nodes/Helpers/NodeDetail.swift | 36 ++- Widgets/MeshActivityAttributes.swift | 6 +- Widgets/WidgetsLiveActivity.swift | 16 +- 18 files changed, 667 insertions(+), 336 deletions(-) create mode 100644 Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift create mode 100644 Meshtastic/Model/Core Data/TelemetryEntity+CoreDataClass.swift create mode 100644 Meshtastic/Model/Core Data/TelemetryEntity+CoreDataProperties.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index a5e013569..4ffdaf937 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -43,6 +43,9 @@ } } } + }, + "--" : { + }, ": %@" : { "localizations" : { @@ -63,6 +66,9 @@ } } } + }, + "?" : { + }, "(Re)define PIN_GPS_EN for your board." : { "localizations" : { @@ -544,6 +550,9 @@ } } } + }, + "☢" : { + }, "🦕 End of life Version 🦖 ☄️" : { "localizations" : { @@ -1337,8 +1346,12 @@ } } } + }, + "Airtime %@" : { + }, "Airtime %@%%" : { + "extractionState" : "stale", "localizations" : { "sr" : { "stringUnit" : { @@ -4176,8 +4189,15 @@ } } } + }, + "Channel Utilization --" : { + + }, + "Channel Utilization %@" : { + }, "Channel Utilization %@%% " : { + "extractionState" : "stale", "localizations" : { "sr" : { "stringUnit" : { @@ -9243,6 +9263,9 @@ } } } + }, + "DISTANCE" : { + }, "Documentation" : { "localizations" : { @@ -22450,6 +22473,9 @@ } } } + }, + "RADIATION" : { + }, "Radio Disconnected" : { "extractionState" : "manual", diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index 0b6e04183..b9eecf933 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -11,6 +11,9 @@ 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; }; 231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; }; 231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; }; + 235FFC3A2D1D8CF0003C95E5 /* TelemetryEntity+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235FFC392D1D8CF0003C95E5 /* TelemetryEntity+CoreDataProperties.swift */; }; + 235FFC3B2D1D8CF0003C95E5 /* TelemetryEntity+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235FFC382D1D8CF0003C95E5 /* TelemetryEntity+CoreDataClass.swift */; }; + 235FFC3D2D1D8DC9003C95E5 /* ManagedAttributePropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235FFC3C2D1D8DC8003C95E5 /* ManagedAttributePropertyWrapper.swift */; }; 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */; }; @@ -270,6 +273,9 @@ 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = ""; }; 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = ""; }; 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = ""; }; + 235FFC382D1D8CF0003C95E5 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = ""; }; + 235FFC392D1D8CF0003C95E5 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = ""; }; + 235FFC3C2D1D8DC8003C95E5 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = ""; }; 237013332D1A0D4F007DBE7F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; @@ -591,6 +597,15 @@ path = "Metrics Columns"; sourceTree = ""; }; + 235FFC372D1D8CCF003C95E5 /* Core Data */ = { + isa = PBXGroup; + children = ( + 235FFC382D1D8CF0003C95E5 /* TelemetryEntity+CoreDataClass.swift */, + 235FFC392D1D8CF0003C95E5 /* TelemetryEntity+CoreDataProperties.swift */, + ); + path = "Core Data"; + sourceTree = ""; + }; 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( @@ -672,6 +687,7 @@ DD007BB12AA59B9A00F5FA12 /* CoreData */ = { isa = PBXGroup; children = ( + 235FFC3C2D1D8DC8003C95E5 /* ManagedAttributePropertyWrapper.swift */, DD58C5F12919AD3C00D5BEFB /* ChannelEntityExtension.swift */, 6DEDA55B2A9592F900321D2E /* MessageEntityExtension.swift */, DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */, @@ -971,6 +987,7 @@ DDC2E18826CE24EE0042C5E4 /* Model */ = { isa = PBXGroup; children = ( + 235FFC372D1D8CCF003C95E5 /* Core Data */, 231B3F1E2D0879BC0069A07D /* Metrics Visualization */, DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */, ); @@ -1389,6 +1406,8 @@ DDDB445429F8AD1600EE2349 /* Data.swift in Sources */, DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */, DD2AD8A8296D2DF9001FF0E7 /* MapViewSwiftUI.swift in Sources */, + 235FFC3A2D1D8CF0003C95E5 /* TelemetryEntity+CoreDataProperties.swift in Sources */, + 235FFC3B2D1D8CF0003C95E5 /* TelemetryEntity+CoreDataClass.swift in Sources */, DDE5B4042B2279A700FCDD05 /* TraceRouteLog.swift in Sources */, DD6193792863875F00E59241 /* SerialConfig.swift in Sources */, DDDB263F2AABEE20003AFCB7 /* NodeList.swift in Sources */, @@ -1439,6 +1458,7 @@ 6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */, D93068DB2B81C85E0066FBC8 /* PowerConfig.swift in Sources */, D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */, + 235FFC3D2D1D8DC9003C95E5 /* ManagedAttributePropertyWrapper.swift in Sources */, DD8EBF43285058FA00426DCA /* DisplayConfig.swift in Sources */, DD964FC42974767D007C176F /* MapViewFitExtension.swift in Sources */, BCE2D3C72C7B0D0A008E6199 /* ShortcutsProvider.swift in Sources */, diff --git a/Meshtastic/Export/WriteCsvFile.swift b/Meshtastic/Export/WriteCsvFile.swift index 3bd98f0a0..656a48410 100644 --- a/Meshtastic/Export/WriteCsvFile.swift +++ b/Meshtastic/Export/WriteCsvFile.swift @@ -17,15 +17,15 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin csvString = "\("battery.level".localized), \("voltage".localized), \("channel.utilization".localized), \("airtime".localized), \("uptime".localized), \("timestamp".localized)" for dm in telemetry where dm.metricsType == 0 { csvString += "\n" - csvString += String(dm.batteryLevel) + csvString += dm.batteryLevel.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.voltage) + csvString += dm.voltage.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.channelUtilization) + csvString += dm.channelUtilization.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.airUtilTx) + csvString += dm.airUtilTx.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.uptimeSeconds) + csvString += dm.uptimeSeconds.map { String($0) } ?? "" csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } @@ -34,31 +34,31 @@ func telemetryToCsvFile(telemetry: [TelemetryEntity], metricsType: Int) -> Strin csvString = "Temperature, Relative Humidity, Barometric Pressure, Indoor Air Quality, Gas Resistance, Wind Direction, Wind Speed, Distance, Lux, White Lux, UV Lux, IR Lux, Radiation, \("timestamp".localized)" for dm in telemetry where dm.metricsType == 1 { csvString += "\n" - csvString += String(dm.temperature.localeTemperature()) + csvString += dm.temperature.map { String($0.localeTemperature()) } ?? "" csvString += ", " - csvString += String(dm.relativeHumidity) + csvString += dm.relativeHumidity.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.barometricPressure) + csvString += dm.barometricPressure.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.iaq) + csvString += dm.iaq.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.gasResistance) + csvString += dm.gasResistance.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.windDirection) + csvString += dm.windDirection.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.windSpeed) + csvString += dm.windSpeed.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.distance) + csvString += dm.distance.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.lux) + csvString += dm.lux.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.whiteLux) + csvString += dm.whiteLux.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.uvLux) + csvString += dm.uvLux.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.irLux) + csvString += dm.irLux.map { String($0) } ?? "" csvString += ", " - csvString += String(dm.radiation) + csvString += dm.radiation.map { String($0) } ?? "" csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized } diff --git a/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift b/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift new file mode 100644 index 000000000..3b123207d --- /dev/null +++ b/Meshtastic/Extensions/CoreData/ManagedAttributePropertyWrapper.swift @@ -0,0 +1,61 @@ +// +// ManagedAttributePropertyWrapper.swift +// Meshtastic +// +// Created by Jake Bordens on 12/26/24. +// +import CoreData + +@propertyWrapper +public struct ManagedAttribute { + private let attributeName: String + private let converter: (NSNumber) -> Value? + + public init(attributeName: String) { + self.attributeName = attributeName + + // Define the converter closure based on the generic type Value + if Value.self == Float.self { + converter = { $0.floatValue as? Value } + } else if Value.self == Double.self { + converter = { $0.doubleValue as? Value } + } else if Value.self == Int.self { + converter = { $0.intValue as? Value } + } else if Value.self == Int8.self { + converter = { $0.int8Value as? Value } + } else if Value.self == Int16.self { + converter = { $0.int16Value as? Value } + } else if Value.self == Int32.self { + converter = { $0.int32Value as? Value } + } else if Value.self == Int64.self { + converter = { $0.int64Value as? Value } + } else { + fatalError("Unsupported type: \(Value.self)") + } + } + + public var wrappedValue: Value? { + get { fatalError("Access via enclosing instance required.") } + set { fatalError("Access via enclosing instance required.") } + } + + public static subscript( + _enclosingInstance observed: EnclosingSelf, + wrapped wrappedKeyPath: KeyPath, + storage storageKeyPath: ReferenceWritableKeyPath> + ) -> Value? { + get { + let wrapper = observed[keyPath: storageKeyPath] + let number = observed.primitiveValue(forKey: wrapper.attributeName) as? NSNumber + return number.flatMap { wrapper.converter($0) } + } + set { + let wrapper = observed[keyPath: storageKeyPath] + if let newValue = newValue { + observed.setPrimitiveValue(NSNumber(value: Double("\(newValue)")!), forKey: wrapper.attributeName) + } else { + observed.setPrimitiveValue(nil, forKey: wrapper.attributeName) + } + } + } +} diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index fd2a9f3de..ff25dc210 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -697,33 +697,33 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage /// Currently only Device Metrics and Environment Telemetry are supported in the app if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) { // Device Metrics - telemetry.airUtilTx = telemetryMessage.deviceMetrics.airUtilTx - telemetry.channelUtilization = telemetryMessage.deviceMetrics.channelUtilization - telemetry.batteryLevel = Int32(telemetryMessage.deviceMetrics.batteryLevel) - telemetry.voltage = telemetryMessage.deviceMetrics.voltage - telemetry.uptimeSeconds = Int32(telemetryMessage.deviceMetrics.uptimeSeconds) + telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx ? telemetryMessage.deviceMetrics.airUtilTx : nil + telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization ? telemetryMessage.deviceMetrics.channelUtilization : nil + telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel ? Int32(telemetryMessage.deviceMetrics.batteryLevel) : nil + telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage ? telemetryMessage.deviceMetrics.voltage : nil + telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds ? Int32(telemetryMessage.deviceMetrics.uptimeSeconds) : nil telemetry.metricsType = 0 Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)") } else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) { // Environment Metrics - telemetry.barometricPressure = telemetryMessage.environmentMetrics.barometricPressure - telemetry.current = telemetryMessage.environmentMetrics.current - telemetry.iaq = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq) - telemetry.gasResistance = telemetryMessage.environmentMetrics.gasResistance - telemetry.irLux = telemetryMessage.environmentMetrics.irLux - telemetry.lux = telemetryMessage.environmentMetrics.lux - telemetry.relativeHumidity = telemetryMessage.environmentMetrics.relativeHumidity - telemetry.temperature = telemetryMessage.environmentMetrics.temperature - telemetry.current = telemetryMessage.environmentMetrics.current - telemetry.distance = telemetryMessage.environmentMetrics.distance - telemetry.uvLux = telemetryMessage.environmentMetrics.uvLux - telemetry.voltage = telemetryMessage.environmentMetrics.voltage - telemetry.weight = telemetryMessage.environmentMetrics.weight - telemetry.windSpeed = telemetryMessage.environmentMetrics.windSpeed - telemetry.windGust = telemetryMessage.environmentMetrics.windGust - telemetry.windLull = telemetryMessage.environmentMetrics.windLull - telemetry.windDirection = Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) - telemetry.radiation = telemetryMessage.environmentMetrics.radiation + telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure ? telemetryMessage.environmentMetrics.barometricPressure : nil + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent ? telemetryMessage.environmentMetrics.current : nil + telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq ? Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq) : nil + telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance ? telemetryMessage.environmentMetrics.gasResistance : nil + telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux ? telemetryMessage.environmentMetrics.irLux : nil + telemetry.lux = telemetryMessage.environmentMetrics.hasLux ? telemetryMessage.environmentMetrics.lux : nil + telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity ? telemetryMessage.environmentMetrics.relativeHumidity : nil + telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature ? telemetryMessage.environmentMetrics.temperature : nil + telemetry.current = telemetryMessage.environmentMetrics.hasCurrent ? telemetryMessage.environmentMetrics.current : nil + telemetry.distance = telemetryMessage.environmentMetrics.hasDistance ? telemetryMessage.environmentMetrics.distance : nil + telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux ? telemetryMessage.environmentMetrics.uvLux : nil + telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage ? telemetryMessage.environmentMetrics.voltage : nil + telemetry.weight = telemetryMessage.environmentMetrics.hasWeight ? telemetryMessage.environmentMetrics.weight : nil + telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed ? telemetryMessage.environmentMetrics.windSpeed : nil + telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust ? telemetryMessage.environmentMetrics.windGust : nil + telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull ? telemetryMessage.environmentMetrics.windLull : nil + telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection ? Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection) : nil + telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation ? telemetryMessage.environmentMetrics.radiation : nil telemetry.metricsType = 1 } else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) { // Local Stats for Live activity @@ -763,7 +763,8 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage // ------------------------ // Low Battery notification if connectedNode == Int64(packet.from) { - if UserDefaults.lowBatteryNotifications && telemetry.batteryLevel > 0 && telemetry.batteryLevel < 4 { + let batteryLevel = telemetry.batteryLevel ?? 0 + if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 { let manager = LocalNotificationManager() manager.notifications = [ Notification( @@ -785,7 +786,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())! let date = Date.now...fifteenMinutesLater - let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: UInt32(telemetry.uptimeSeconds), + let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) }, channelUtilization: telemetry.channelUtilization, airtime: telemetry.airUtilTx, sentPackets: UInt32(telemetry.numPacketsTx), diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents index 55ba48a50..5b50beb3f 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 48.xcdatamodel/contents @@ -390,7 +390,7 @@ - + diff --git a/Meshtastic/Model/Core Data/TelemetryEntity+CoreDataClass.swift b/Meshtastic/Model/Core Data/TelemetryEntity+CoreDataClass.swift new file mode 100644 index 000000000..a3fb3d7b8 --- /dev/null +++ b/Meshtastic/Model/Core Data/TelemetryEntity+CoreDataClass.swift @@ -0,0 +1,40 @@ +// +// TelemetryEntity+CoreDataClass.swift +// +// +// Created by Jake Bordens on 12/26/24. +// +// + +import Foundation +import CoreData + +@objc(TelemetryEntity) +public class TelemetryEntity: NSManagedObject, Identifiable { + + @ManagedAttribute(attributeName: "airUtilTx") public var airUtilTx: Float? + @ManagedAttribute(attributeName: "barometricPressure") public var barometricPressure: Float? + @ManagedAttribute(attributeName: "batteryLevel") public var batteryLevel: Int32? + @ManagedAttribute(attributeName: "channelUtilization") public var channelUtilization: Float? + @ManagedAttribute(attributeName: "current") public var current: Float? + @ManagedAttribute(attributeName: "distance") public var distance: Float? + @ManagedAttribute(attributeName: "gasResistance") public var gasResistance: Float? + @ManagedAttribute(attributeName: "iaq") public var iaq: Int32? + @ManagedAttribute(attributeName: "irLux") public var irLux: Float? + @ManagedAttribute(attributeName: "lux") public var lux: Float? + @ManagedAttribute(attributeName: "radiation") public var radiation: Float? + @ManagedAttribute(attributeName: "relativeHumidity") public var relativeHumidity: Float? + @ManagedAttribute(attributeName: "rssi") public var rssi: Int32? + @ManagedAttribute(attributeName: "snr") public var snr: Float? + @ManagedAttribute(attributeName: "temperature") public var temperature: Float? + @ManagedAttribute(attributeName: "uptimeSeconds") public var uptimeSeconds: Int32? + @ManagedAttribute(attributeName: "uvLux") public var uvLux: Float? + @ManagedAttribute(attributeName: "voltage") public var voltage: Float? + @ManagedAttribute(attributeName: "weight") public var weight: Float? + @ManagedAttribute(attributeName: "whiteLux") public var whiteLux: Float? + @ManagedAttribute(attributeName: "windDirection") public var windDirection: Int32? + @ManagedAttribute(attributeName: "windGust") public var windGust: Float? + @ManagedAttribute(attributeName: "windLull") public var windLull: Float? + @ManagedAttribute(attributeName: "windSpeed") public var windSpeed: Float? + +} diff --git a/Meshtastic/Model/Core Data/TelemetryEntity+CoreDataProperties.swift b/Meshtastic/Model/Core Data/TelemetryEntity+CoreDataProperties.swift new file mode 100644 index 000000000..a60d338ff --- /dev/null +++ b/Meshtastic/Model/Core Data/TelemetryEntity+CoreDataProperties.swift @@ -0,0 +1,30 @@ +// +// TelemetryEntity+CoreDataProperties.swift +// +// +// Created by Jake Bordens on 12/26/24. +// +// + +import Foundation +import CoreData + +extension TelemetryEntity { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "TelemetryEntity") + } + + @NSManaged public var time: Date? + @NSManaged public var metricsType: Int32 + @NSManaged public var numOnlineNodes: Int32 + @NSManaged public var numPacketsRx: Int32 + @NSManaged public var numPacketsRxBad: Int32 + @NSManaged public var numPacketsTx: Int32 + @NSManaged public var numRxDupe: Int32 + @NSManaged public var numTotalNodes: Int32 + @NSManaged public var numTxRelay: Int32 + @NSManaged public var numTxRelayCanceled: Int32 + @NSManaged public var nodeTelemetry: NodeInfoEntity? + +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift index fbff2fb07..00140c72e 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -40,7 +40,7 @@ class MetricsChartSeries: ObservableObject { let initialYAxisRange: ClosedRange? let minumumYAxisSpan: Float? - + // Main initializer init( id: String, @@ -53,7 +53,7 @@ class MetricsChartSeries: ObservableObject { visible: Bool = true, foregroundStyle: @escaping ((ClosedRange?) -> ForegroundStyle?) = { _ in nil }, @ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange?, Date, Value) -> ChartBody? - ) where Value: Plottable & Comparable { + ) { // This works because TelemetryEntity is an NSManagedObject and derrived from NSObject self.id = id @@ -72,9 +72,15 @@ class MetricsChartSeries: ObservableObject { } self.valueClosure = { te in if let conversion { - return conversion(te[keyPath: keyPath]).floatValue + if let value = conversion(te[keyPath: keyPath]) as? (any Plottable) { + return value.floatValue ?? 0.0 + } + } else { + if let value = te[keyPath: keyPath] as? (any Plottable) { + return value.floatValue + } } - return te[keyPath: keyPath].floatValue + return nil } } diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift index 0dbc00bc0..05ca69da7 100644 --- a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -126,12 +126,12 @@ class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplacea } } } - + func saveDefaults(forNode node: NodeInfoEntity) { let series = self.visible.map( { $0.id } ) node.telemetrySeries = series } - + // Collection conformance typealias Index = Int typealias Element = MetricsChartSeries diff --git a/Meshtastic/Views/Helpers/BatteryCompact.swift b/Meshtastic/Views/Helpers/BatteryCompact.swift index bc714da61..692ccbaa0 100644 --- a/Meshtastic/Views/Helpers/BatteryCompact.swift +++ b/Meshtastic/Views/Helpers/BatteryCompact.swift @@ -7,59 +7,72 @@ import SwiftUI struct BatteryCompact: View { - var batteryLevel: Int32 + var batteryLevel: Int32? var font: Font var iconFont: Font var color: Color var body: some View { HStack(alignment: .center, spacing: 0) { - if batteryLevel == 100 { - Image(systemName: "battery.100.bolt") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 100 && batteryLevel > 74 { - Image(systemName: "battery.75") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 75 && batteryLevel > 49 { - Image(systemName: "battery.50") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 50 && batteryLevel > 14 { - Image(systemName: "battery.25") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) - } else if batteryLevel < 15 && batteryLevel > 0 { + if let batteryLevel { + if batteryLevel == 100 { + Image(systemName: "battery.100.bolt") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 100 && batteryLevel > 74 { + Image(systemName: "battery.75") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 75 && batteryLevel > 49 { + Image(systemName: "battery.50") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 50 && batteryLevel > 14 { + Image(systemName: "battery.25") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel < 15 && batteryLevel > 0 { + Image(systemName: "battery.0") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } else if batteryLevel == 0 { + Image(systemName: "battery.0") + .font(iconFont) + .foregroundColor(.red) + .symbolRenderingMode(.multicolor) + } else if batteryLevel > 100 { + Image(systemName: "powerplug") + .font(iconFont) + .foregroundColor(color) + .symbolRenderingMode(.multicolor) + } + } else { Image(systemName: "battery.0") .font(iconFont) .foregroundColor(color) .symbolRenderingMode(.multicolor) - } else if batteryLevel == 0 { - Image(systemName: "battery.0") - .font(iconFont) - .foregroundColor(.red) - .symbolRenderingMode(.multicolor) - } else if batteryLevel > 100 { - Image(systemName: "powerplug") - .font(iconFont) - .foregroundColor(color) - .symbolRenderingMode(.multicolor) } - if batteryLevel > 100 { - Text("PWD") - .foregroundStyle(.secondary) - .font(font) - } else if batteryLevel == 100 { - Text("CHG") - .foregroundStyle(.secondary) - .font(font) + if let batteryLevel { + if batteryLevel > 100 { + Text("PWD") + .foregroundStyle(.secondary) + .font(font) + } else if batteryLevel == 100 { + Text("CHG") + .foregroundStyle(.secondary) + .font(font) + } else { + Text("\(batteryLevel)%") + .foregroundStyle(.secondary) + .font(font) + } } else { - Text("\(batteryLevel)%") + Text("?") .foregroundStyle(.secondary) .font(font) } diff --git a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift index e48675449..e696bd7df 100644 --- a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift +++ b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift @@ -116,7 +116,7 @@ struct WeatherConditionsCompactWidget: View { struct HumidityCompactWidget: View { let humidity: Int - let dewPoint: String + let dewPoint: String? var body: some View { VStack(alignment: .leading) { HStack(spacing: 5.0) { @@ -129,11 +129,13 @@ struct HumidityCompactWidget: View { Text("\(humidity)%") .font(.largeTitle) .padding(.bottom, 5) - Text("The dew point is \(dewPoint) right now.") - .lineLimit(3) - .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) - .fixedSize(horizontal: false, vertical: true) - .font(.caption2) + if let dewPoint { + Text("The dew point is \(dewPoint) right now.") + .lineLimit(3) + .allowsTightening(/*@START_MENU_TOKEN@*/true/*@END_MENU_TOKEN@*/) + .fixedSize(horizontal: false, vertical: true) + .font(.caption2) + } } .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) .padding() @@ -168,17 +170,19 @@ struct PressureCompactWidget: View { struct WindCompactWidget: View { let speed: String - let gust: String - let direction: String + let gust: String? + let direction: String? var body: some View { VStack(alignment: .leading) { Label { Text("WIND") } icon: { Image(systemName: "wind").foregroundColor(.accentColor) } - Text("\(direction)") - .font(gust.isEmpty ? .callout : .caption) - .padding(.bottom, 10) + if let direction { + Text("\(direction)") + .font((gust ?? "").isEmpty ? .callout : .caption) + .padding(.bottom, 10) + } Text(speed) - .font(gust.isEmpty ? .system(size: 45) : .system(size: 35)) - if !gust.isEmpty { + .font((gust ?? "").isEmpty ? .system(size: 45) : .system(size: 35)) + if let gust, !gust.isEmpty { Text("Gusts \(gust)") } } @@ -188,6 +192,58 @@ struct WindCompactWidget: View { } } +struct RadiationCompactWidget: View { + let radiation: String + let unit: String + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Text("☢") + .font(.system(size: 30, design: .monospaced)) + .foregroundColor(.accentColor) + Text("RADIATION") + .font(.callout) + } + HStack { + Text("\(radiation)") + .font(radiation.length < 4 ? .system(size: 54) : .system(size: 38) ) + Text(unit) + .font(.system(size: 14)) + } + } + .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) + .padding() + .background(.tertiary, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +struct DistanceCompactWidget: View { + let distance: String + let unit: String + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "ruler") + .imageScale(.small) + .foregroundColor(.accentColor) + Text("DISTANCE") + .font(.callout) + } + HStack { + Text("\(distance)") + .font(distance.length < 4 ? .system(size: 62) : .system(size: 46) ) + Text(unit) + .font(.system(size: 14)) + } + } + .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) + .padding() + .background(.tertiary, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + /// Magnus Formula func calculateDewPoint(temp: Float, relativeHumidity: Float) -> Double { let a: Float = 17.27 diff --git a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift index 0239e476d..15e63cc02 100644 --- a/Meshtastic/Views/Nodes/DeviceMetricsLog.swift +++ b/Meshtastic/Views/Nodes/DeviceMetricsLog.swift @@ -38,26 +38,30 @@ struct DeviceMetricsLog: View { GroupBox(label: Label("\(deviceMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) { Chart { ForEach(chartData, id: \.self) { point in - Plot { - LineMark( - x: .value("x", point.time!), - y: .value("y", point.batteryLevel) - ) + if let batteryLevel = point.batteryLevel { + Plot { + LineMark( + x: .value("x", point.time!), + y: .value("y", batteryLevel) + ) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(batteryLevel)") + .foregroundStyle(batteryChartColor) + .interpolationMethod(.linear) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.batteryLevel)") - .foregroundStyle(batteryChartColor) - .interpolationMethod(.linear) - Plot { - PointMark( - x: .value("x", point.time!), - y: .value("y", point.channelUtilization) - ) - .symbolSize(25) + if let channelUtilization = point.channelUtilization { + Plot { + PointMark( + x: .value("x", point.time!), + y: .value("y", channelUtilization) + ) + .symbolSize(25) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(channelUtilization)") + .foregroundStyle(channelUtilizationChartColor) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.channelUtilization)") - .foregroundStyle(channelUtilizationChartColor) if let chartSelection { RuleMark(x: .value("Second", chartSelection, unit: .second)) .foregroundStyle(.tertiary.opacity(0.5)) @@ -81,16 +85,18 @@ struct DeviceMetricsLog: View { RuleMark(y: .value("Network Status Red", 50)) .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 10])) .foregroundStyle(.red) - Plot { - PointMark( - x: .value("x", point.time!), - y: .value("y", point.airUtilTx) - ) - .symbolSize(25) + if let airUtilTx = point.airUtilTx { + Plot { + PointMark( + x: .value("x", point.time!), + y: .value("y", airUtilTx) + ) + .symbolSize(25) + } + .accessibilityLabel("Line Series") + .accessibilityValue("X: \(point.time!), Y: \(airUtilTx)") + .foregroundStyle(airtimeChartColor) } - .accessibilityLabel("Line Series") - .accessibilityValue("X: \(point.time!), Y: \(point.airUtilTx)") - .foregroundStyle(airtimeChartColor) } } .chartXAxis(content: { @@ -122,14 +128,19 @@ struct DeviceMetricsLog: View { Image(systemName: "bolt") .font(.caption) .symbolRenderingMode(.multicolor) - Text("Volts \(String(format: "%.2f", dm.voltage)) ") + Text("Volts \(dm.voltage.map { String(format: "%.2f", $0) } ?? "--") ") .font(.caption2) BatteryCompact(batteryLevel: dm.batteryLevel, font: .caption, iconFont: .callout, color: .accentColor) } HStack { - Text("Channel Utilization \(String(format: "%.2f", dm.channelUtilization))% ") - .foregroundColor(dm.channelUtilization < 25 ? .green : (dm.channelUtilization > 50 ? .red : .orange)) - Text("Airtime \(String(format: "%.2f", dm.airUtilTx))%") + if let channelUtilization = dm.channelUtilization { + Text("Channel Utilization \(String(format: "%.2f%%", channelUtilization))") + .foregroundColor(channelUtilization < 25 ? .green : (channelUtilization > 50 ? .red : .orange)) + } else { + Text("Channel Utilization --") + .foregroundColor(.gray) + } + Text("Airtime \(dm.airUtilTx.map { String(format: "%.2f%%", $0) } ?? "--")") .foregroundColor(.secondary) Spacer() } @@ -141,27 +152,33 @@ struct DeviceMetricsLog: View { /// Multi Column table for ipads and mac Table(deviceMetrics, selection: $selection, sortOrder: $sortOrder) { TableColumn("battery.level") { dm in - if dm.batteryLevel > 100 { + if (dm.batteryLevel ?? 0) > 100 { Text("Powered") } else { - Text("\(String(dm.batteryLevel))%") + dm.batteryLevel.map { Text("\(String($0))%") } ?? Text("--") } } TableColumn("voltage") { dm in - Text("\(String(format: "%.2f", dm.voltage))") + dm.voltage.map { Text("\(String(format: "%.2f", $0))") } ?? Text("--") } TableColumn("channel.utilization") { dm in - Text("\(String(format: "%.2f", dm.channelUtilization))%") - .foregroundColor(dm.channelUtilization < 25 ? .green : (dm.channelUtilization > 50 ? .red : .orange)) + dm.channelUtilization.map { channelUtilization in + Text("\(String(format: "%.2f", channelUtilization))%") + .foregroundColor(channelUtilization < 25 ? .green : (channelUtilization > 50 ? .red : .orange)) + } ?? Text("--") } TableColumn("airtime") { dm in - Text("\(String(format: "%.2f", dm.airUtilTx))%") + dm.airUtilTx.map { Text("\(String(format: "%.2f", $0))%") } ?? Text("--") } TableColumn("uptime") { dm in let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) - let components = (now.. 0 { + if let dm = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 0")).lastObject as? TelemetryEntity, let uptimeSeconds = dm.uptimeSeconds { HStack { Label { Text("\("uptime".localized)") @@ -156,7 +156,7 @@ struct NodeDetail: View { Spacer() let now = Date.now - let later = now + TimeInterval(dm.uptimeSeconds) + let later = now + TimeInterval(uptimeSeconds) let uptime = (now.. 0.0 { - HumidityCompactWidget(humidity: Int(node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0), dewPoint: String(format: "%.0f", calculateDewPoint(temp: node.latestEnvironmentMetrics?.temperature ?? 0.0, relativeHumidity: node.latestEnvironmentMetrics?.relativeHumidity ?? 0.0)) + "°") + if let temperature = node.latestEnvironmentMetrics?.temperature?.shortFormattedTemperature() { + WeatherConditionsCompactWidget(temperature: String(temperature), symbolName: "cloud.sun", description: "TEMP") } - if node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 > 0.0 { - PressureCompactWidget(pressure: String(format: "%.2f", node.latestEnvironmentMetrics?.barometricPressure ?? 0.0), unit: "hPA", low: node.latestEnvironmentMetrics?.barometricPressure ?? 0.0 <= 1009.144) + if let humidity = node.latestEnvironmentMetrics?.relativeHumidity { + if let temperature = node.latestEnvironmentMetrics?.temperature { + HumidityCompactWidget(humidity: Int(humidity), dewPoint: String(format: "%.0f", calculateDewPoint(temp: temperature, relativeHumidity: humidity)) + "°") + } else { + HumidityCompactWidget(humidity: Int(humidity), dewPoint: nil) + } } - if node.latestEnvironmentMetrics?.windSpeed ?? 0.0 > 0.0 { - let windSpeed = Measurement(value: Double(node.latestEnvironmentMetrics?.windSpeed ?? 0.0), unit: UnitSpeed.metersPerSecond) - let windGust = Measurement(value: Double(node.latestEnvironmentMetrics?.windGust ?? 0.0), unit: UnitSpeed.metersPerSecond) - let direction = cardinalValue(from: Double(node.latestEnvironmentMetrics?.windDirection ?? 0)) + if let pressure = node.latestEnvironmentMetrics?.barometricPressure { + PressureCompactWidget(pressure: String(format: "%.2f", pressure), unit: "hPA", low: pressure <= 1009.144) + } + if let windSpeed = node.latestEnvironmentMetrics?.windSpeed { + let windSpeed = Measurement(value: Double(windSpeed), unit: UnitSpeed.metersPerSecond) + let windGust = node.latestEnvironmentMetrics?.windGust.map { Measurement(value: Double($0), unit: UnitSpeed.metersPerSecond) } + let direction = node.latestEnvironmentMetrics?.windDirection.map { cardinalValue(from: Double($0)) } WindCompactWidget(speed: windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))), - gust: node.latestEnvironmentMetrics?.windGust ?? 0.0 > 0.0 ? windGust.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))) : "", direction: direction) + gust: windGust?.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))), direction: direction) + } + if let distance = node.latestEnvironmentMetrics?.distance { + DistanceCompactWidget(distance: String(format: "%.1f", distance), unit: "mm") + } + if let radiation = node.latestEnvironmentMetrics?.radiation { + RadiationCompactWidget(radiation: String(format: "%.1f", radiation), unit: "µR/h") } } .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) diff --git a/Widgets/MeshActivityAttributes.swift b/Widgets/MeshActivityAttributes.swift index 373765313..8d7ea9af8 100644 --- a/Widgets/MeshActivityAttributes.swift +++ b/Widgets/MeshActivityAttributes.swift @@ -15,9 +15,9 @@ struct MeshActivityAttributes: ActivityAttributes { public typealias MeshActivityStatus = ContentState public struct ContentState: Codable, Hashable { // Dynamic stateful properties about your activity go here! - var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var uptimeSeconds: UInt32? + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 diff --git a/Widgets/WidgetsLiveActivity.swift b/Widgets/WidgetsLiveActivity.swift index 7c6396a35..8faee2287 100644 --- a/Widgets/WidgetsLiveActivity.swift +++ b/Widgets/WidgetsLiveActivity.swift @@ -42,11 +42,11 @@ struct WidgetsLiveActivity: Widget { .foregroundStyle(.secondary) .fixedSize() } - Text("\(String(format: "Ch. Util: %.2f", context.state.channelUtilization))%") + Text("\(context.state.channelUtilization.map { String(format: "Ch. Util: %.2f", $0) } ?? "--")%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() - Text("\(String(format: "Airtime: %.2f", context.state.airtime))%") + Text("\(context.state.airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") .font(.caption2) .foregroundStyle(.secondary) .fixedSize() @@ -118,7 +118,7 @@ struct WidgetsLiveActivity: Widget { struct WidgetsLiveActivity_Previews: PreviewProvider { static let attributes = MeshActivityAttributes(nodeNum: 123456789, name: "RAK Compact Rotary Handset Gray 8E6G") - static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100 , packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) + static let state = MeshActivityAttributes.ContentState(uptimeSeconds: 600, channelUtilization: 1.2, airtime: 3.5, sentPackets: 12587, receivedPackets: 12555, badReceivedPackets: 800, dupeReceivedPackets: 100, packetsSentRelay: 250, packetsCanceledRelay: 372, nodesOnline: 99, totalNodes: 100, timerRange: Date.now...Date(timeIntervalSinceNow: 300)) static var previews: some View { attributes @@ -141,8 +141,8 @@ struct LiveActivityView: View { var nodeName: String var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 @@ -180,8 +180,8 @@ struct NodeInfoView: View { var nodeName: String var uptimeSeconds: UInt32 - var channelUtilization: Float - var airtime: Float + var channelUtilization: Float? + var airtime: Float? var sentPackets: UInt32 var receivedPackets: UInt32 var badReceivedPackets: UInt32 @@ -199,7 +199,7 @@ struct NodeInfoView: View { .font(nodeName.count > 14 ? .callout : .title3) .fontWeight(.semibold) .foregroundStyle(.tint) - Text("\(String(format: "Ch. Util: %.2f", channelUtilization))% \(String(format: "Airtime: %.2f", airtime))%") + Text("\(channelUtilization.map { String(format: "Ch. Util: %.2f", $0 ) } ?? "--")% \(airtime.map { String(format: "Airtime: %.2f", $0) } ?? "--")%") .font(.caption) .fontWeight(.medium) .foregroundStyle(.secondary) From 7c197f6535c6930bd9c481ee98a52e10ac1c6391 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Thu, 26 Dec 2024 16:56:59 -0500 Subject: [PATCH 6/8] Added whiteLux to mesh packet receive --- Meshtastic/Helpers/MeshPackets.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index ff25dc210..bdd48c4c6 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -719,6 +719,7 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux ? telemetryMessage.environmentMetrics.uvLux : nil telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage ? telemetryMessage.environmentMetrics.voltage : nil telemetry.weight = telemetryMessage.environmentMetrics.hasWeight ? telemetryMessage.environmentMetrics.weight : nil + telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux ? telemetryMessage.environmentMetrics.whiteLux : nil telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed ? telemetryMessage.environmentMetrics.windSpeed : nil telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust ? telemetryMessage.environmentMetrics.windGust : nil telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull ? telemetryMessage.environmentMetrics.windLull : nil From 3cdd095408aae1e99dbafd9498323361993e059a Mon Sep 17 00:00:00 2001 From: Jake-B Date: Thu, 26 Dec 2024 16:57:40 -0500 Subject: [PATCH 7/8] Fixed typo in filename --- Meshtastic.xcodeproj/project.pbxproj | 8 ++++---- ...DefaultSeries.swift => EnvironmentDefaultSeries.swift} | 0 2 files changed, 4 insertions(+), 4 deletions(-) rename Meshtastic/Views/Nodes/Helpers/Metrics Columns/{EnviornmentDefaultSeries.swift => EnvironmentDefaultSeries.swift} (100%) diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index b9eecf933..2d4f52411 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -16,7 +16,7 @@ 235FFC3D2D1D8DC9003C95E5 /* ManagedAttributePropertyWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235FFC3C2D1D8DC8003C95E5 /* ManagedAttributePropertyWrapper.swift */; }; 2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; }; 2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; }; - 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */; }; + 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; }; 251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; }; 251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; }; 2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; }; @@ -279,7 +279,7 @@ 237013332D1A0D4F007DBE7F /* MeshtasticDataModelV 48.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 48.xcdatamodel"; sourceTree = ""; }; 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = ""; }; 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = ""; }; - 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = ""; }; + 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = ""; }; 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = ""; }; 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = ""; }; 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = ""; }; @@ -591,7 +591,7 @@ isa = PBXGroup; children = ( 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */, - 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */, + 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */, 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, ); path = "Metrics Columns"; @@ -1383,7 +1383,7 @@ DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */, 251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */, DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, - 2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */, + 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */, DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */, diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift similarity index 100% rename from Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift rename to Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift From cd10961595f9cfedf80ba3e040ffa61600b94796 Mon Sep 17 00:00:00 2001 From: Jake-B Date: Tue, 31 Dec 2024 22:20:07 -0500 Subject: [PATCH 8/8] Added support for the `weight` telemetry field --- Localizable.xcstrings | 3 ++ .../Weather/LocalWeatherConditions.swift | 28 ++++++++++++++++++- .../EnvironmentDefaultColumns.swift | 24 +++++++++++++++- .../EnvironmentDefaultSeries.swift | 26 +++++++++++++++++ .../Views/Nodes/Helpers/NodeDetail.swift | 3 ++ 5 files changed, 82 insertions(+), 2 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4ffdaf937..e9fc084c9 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -31070,6 +31070,9 @@ } } } + }, + "WEIGHT" : { + }, "What does the lock mean?" : { "localizations" : { diff --git a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift index e696bd7df..b2d502606 100644 --- a/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift +++ b/Meshtastic/Views/Helpers/Weather/LocalWeatherConditions.swift @@ -233,7 +233,33 @@ struct DistanceCompactWidget: View { } HStack { Text("\(distance)") - .font(distance.length < 4 ? .system(size: 62) : .system(size: 46) ) + .font(distance.length < 4 ? .system(size: 50) : .system(size: 40) ) + Text(unit) + .font(.system(size: 14)) + } + } + .frame(minWidth: 100, idealWidth: 125, maxWidth: 150, minHeight: 120, idealHeight: 130, maxHeight: 140) + .padding() + .background(.tertiary, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) + } +} + +struct WeightCompactWidget: View { + let weight: String + let unit: String + + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .firstTextBaseline) { + Image(systemName: "scalemass") + .imageScale(.small) + .foregroundColor(.accentColor) + Text("WEIGHT") + .font(.callout) + } + HStack { + Text("\(weight)") + .font(weight.length < 4 ? .system(size: 50) : .system(size: 40) ) Text(unit) .font(.system(size: 14)) } diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift index 5008d0f0a..f5d3a4d28 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -222,7 +222,29 @@ extension MetricsColumnList { ) } ?? Text("--") }), - + + //Weight + MetricsTableColumn( + id: "weight", + keyPath: \.weight, + name: "Weight", + abbreviatedName: "kg", + minWidth: 30, maxWidth: 60, + visible: false, + tableBody: { _, weight in + weight.map { + let weight = Measurement( + value: Double($0), unit: UnitMass.kilograms) + return Text( + weight.formatted( + .measurement( + width: .abbreviated, + numberFormatStyle: .number.precision( + .fractionLength(0)))) + ) + } ?? Text("--") + }), + // Timestamp Series Configuration -- for use in table only MetricsTableColumn( id: "time", diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift index 5f9e48e07..8e5d9064c 100644 --- a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultSeries.swift @@ -356,6 +356,32 @@ extension MetricsSeriesList { .alignsMarkStylesWithPlotArea() } }), + + // Radiation + MetricsChartSeries( + id: "weight", + keyPath: \.weight, + name: "Weight", + abbreviatedName: "kg", + visible: false, + foregroundStyle: { _ in + .linearGradient( + colors: [Color(UIColor.brown.darker(componentDelta: 0.5)), .brown], + startPoint: .bottom, endPoint: .top + ) + }, + chartBody: { series, _, time, radiation in + if let radiation { + LineMark( + x: .value("Time", time), + y: .value(series.abbreviatedName, radiation) + ) + .interpolationMethod(.catmullRom) + .foregroundStyle(by: .value("Series", series.abbreviatedName)) + .lineStyle(StrokeStyle(lineWidth: 4)) + .alignsMarkStylesWithPlotArea() + } + }) ]) } } diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index cdc155fcb..a8c2af2d7 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -243,6 +243,9 @@ struct NodeDetail: View { if let radiation = node.latestEnvironmentMetrics?.radiation { RadiationCompactWidget(radiation: String(format: "%.1f", radiation), unit: "µR/h") } + if let weight = node.latestEnvironmentMetrics?.weight { + WeightCompactWidget(weight: String(format: "%.1f", weight), unit: "kg") + } } .padding(node.latestEnvironmentMetrics?.iaq ?? -1 > 0 ? .bottom : .vertical) }