Merge pull request #1031 from meshtastic/2.5.14

2.5.14
This commit is contained in:
Garth Vander Houwen 2024-12-22 20:23:01 -08:00 committed by GitHub
commit 43884e241b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1085 additions and 208 deletions

View file

@ -297,6 +297,7 @@
}
},
"%@ hPa" : {
"extractionState" : "stale",
"localizations" : {
"sr" : {
"stringUnit" : {
@ -2390,6 +2391,7 @@
}
},
"Bad" : {
"extractionState" : "stale",
"localizations" : {
"sr" : {
"stringUnit" : {
@ -4517,6 +4519,9 @@
}
}
}
},
"Chart" : {
},
"CHG" : {
"localizations" : {
@ -4869,6 +4874,9 @@
}
}
}
},
"Config" : {
},
"config.module.paxcounter.enabled.description" : {
"localizations" : {
@ -18674,7 +18682,7 @@
}
},
"mesh.log.traceroute.received.direct %@" : {
"extractionState" : "migrated",
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
@ -19348,6 +19356,9 @@
}
}
}
},
"Metric" : {
},
"Minimum Distance" : {
"localizations" : {
@ -26503,6 +26514,9 @@
}
}
}
},
"Series" : {
},
"Server" : {
"localizations" : {
@ -27718,6 +27732,9 @@
}
}
}
},
"Table" : {
},
"tapback" : {
"localizations" : {
@ -29817,6 +29834,7 @@
}
},
"Trace route received directly by %@ with a SNR of %@ dB" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {

View file

@ -7,6 +7,13 @@
objects = {
/* Begin PBXBuildFile section */
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
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 */; };
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 */; };
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 */; };
@ -259,6 +266,13 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnviornmentDefaultSeries.swift; sourceTree = "<group>"; };
251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = "<group>"; };
251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = "<group>"; };
251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = "<group>"; };
@ -555,6 +569,27 @@
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
231B3F1E2D0879BC0069A07D /* Metrics Visualization */ = {
isa = PBXGroup;
children = (
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */,
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */,
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */,
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */,
);
path = "Metrics Visualization";
sourceTree = "<group>";
};
231B3F232D087C020069A07D /* Metrics Columns */ = {
isa = PBXGroup;
children = (
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */,
2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */,
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */,
);
path = "Metrics Columns";
sourceTree = "<group>";
};
251926882C3BAF2E00249DF5 /* Actions */ = {
isa = PBXGroup;
children = (
@ -935,6 +970,7 @@
DDC2E18826CE24EE0042C5E4 /* Model */ = {
isa = PBXGroup;
children = (
231B3F1E2D0879BC0069A07D /* Metrics Visualization */,
DD23A50E26FD1B4400D9B90C /* PeripheralModel.swift */,
);
path = Model;
@ -1036,6 +1072,7 @@
DDDB26402AABEF7B003AFCB7 /* Helpers */ = {
isa = PBXGroup;
children = (
231B3F232D087C020069A07D /* Metrics Columns */,
DDAD49EB2AFAE82500B4425D /* Map */,
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */,
DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */,
@ -1311,6 +1348,7 @@
DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */,
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */,
DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */,
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */,
DDC2E1A726CEB3400042C5E4 /* LocationHelper.swift in Sources */,
DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */,
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */,
@ -1327,11 +1365,13 @@
DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */,
251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */,
DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */,
2373AE172D0A26620086C749 /* EnviornmentDefaultSeries.swift in Sources */,
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */,
DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */,
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */,
DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */,
DDDB445229F8ACF900EE2349 /* Date.swift in Sources */,
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */,
DDC4D568275499A500A4208E /* Persistence.swift in Sources */,
DDD6EEAF29BC024700383354 /* Firmware.swift in Sources */,
DD77093B2AA1ABB8007A8BF0 /* BluetoothTips.swift in Sources */,
@ -1341,7 +1381,9 @@
DD964FBD296E6B01007C176F /* EmojiOnlyTextField.swift in Sources */,
DD8169FF272476C700F4AB02 /* LogDocument.swift in Sources */,
DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */,
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */,
25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */,
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */,
DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */,
DDDB445429F8AD1600EE2349 /* Data.swift in Sources */,
DDDB26462AACC0B7003AFCB7 /* NodeInfoItem.swift in Sources */,
@ -1425,6 +1467,8 @@
DD3CC24C2C498D6C001BD3A2 /* BatteryCompact.swift in Sources */,
BCB613812C67290800485544 /* SendWaypointIntent.swift in Sources */,
DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */,
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */,
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */,
DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */,
DDC3B274283F411B00AC321C /* LastHeardText.swift in Sources */,
DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */,
@ -1710,7 +1754,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.13;
MARKETING_VERSION = 2.5.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1744,7 +1788,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 2.5.13;
MARKETING_VERSION = 2.5.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
@ -1776,7 +1820,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.5.13;
MARKETING_VERSION = 2.5.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -1809,7 +1853,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 2.5.13;
MARKETING_VERSION = 2.5.14;
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -8,6 +8,14 @@
import Foundation
import Charts
extension Measurement where UnitType == UnitAngle {
func reciprocal() -> Measurement {
var recip = self.converted(to: .degrees)
recip.value = (recip.value + 180).truncatingRemainder(dividingBy: 360)
return recip.converted(to: self.unit)
}
}
struct PlottableMeasurement<UnitType: Unit> {
var measurement: Measurement<UnitType>
}

View file

@ -834,45 +834,92 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
if let routingMessage = try? RouteDiscovery(serializedBytes: decodedInfo.packet.decoded.payload) {
let traceRoute = getTraceRoute(id: Int64(decodedInfo.packet.decoded.requestID), context: context)
traceRoute?.response = true
if routingMessage.route.count == 0 {
// Routing messages snr values are snr * 4 stored as an int
// If a traceroute snr value is unknown this field will contain INT8_MIN or -128
// After converting to a float here, -32 is our unknown value.
let snr = routingMessage.snrBack.count > 0 ? (Float(routingMessage.snrBack[0]) / 4) : -32
traceRoute?.snr = snr
let logString = String.localizedStringWithFormat("mesh.log.traceroute.received.direct %@".localized, String(snr))
MeshLogger.log("🪧 \(logString)")
} else {
guard let connectedNode = getNodeInfo(id: Int64(connectedPeripheral.num), context: context) else {
return
guard let connectedNode = getNodeInfo(id: Int64(connectedPeripheral.num), context: context) else {
return
}
var hopNodes: [TraceRouteHopEntity] = []
let connectedHop = TraceRouteHopEntity(context: context)
connectedHop.time = Date()
connectedHop.num = connectedPeripheral.num
connectedHop.name = connectedNode.user?.longName ?? "???"
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
connectedHop.altitude = mostRecent.altitude
connectedHop.latitudeI = mostRecent.latitudeI
connectedHop.longitudeI = mostRecent.longitudeI
traceRoute?.hasPositions = true
}
var routeString = "\(connectedNode.user?.longName ?? "???") --> "
hopNodes.append(connectedHop)
traceRoute?.hopsTowards = Int32(routingMessage.route.count)
for (index, node) in routingMessage.route.enumerated() {
var hopNode = getNodeInfo(id: Int64(node), context: context)
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
hopNode = createNodeInfo(num: Int64(node), context: context)
}
var hopNodes: [TraceRouteHopEntity] = []
let connectedHop = TraceRouteHopEntity(context: context)
connectedHop.time = Date()
connectedHop.num = connectedPeripheral.num
connectedHop.name = connectedNode.user?.longName ?? "???"
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
connectedHop.altitude = mostRecent.altitude
connectedHop.latitudeI = mostRecent.latitudeI
connectedHop.longitudeI = mostRecent.longitudeI
traceRoute?.hasPositions = true
let traceRouteHop = TraceRouteHopEntity(context: context)
traceRouteHop.time = Date()
if routingMessage.snrTowards.count >= index + 1 {
traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4
} else {
// If no snr in route, set unknown
traceRouteHop.snr = -32
}
var routeString = "\(connectedNode.user?.longName ?? "???") --> "
hopNodes.append(connectedHop)
traceRoute?.hopsTowards = Int32(routingMessage.route.count)
for (index, node) in routingMessage.route.enumerated() {
if let hn = hopNode, hn.hasPositions {
if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
traceRouteHop.altitude = mostRecent.altitude
traceRouteHop.latitudeI = mostRecent.latitudeI
traceRouteHop.longitudeI = mostRecent.longitudeI
traceRoute?.hasPositions = true
}
}
traceRouteHop.num = hopNode?.num ?? 0
if hopNode != nil {
if decodedInfo.packet.rxTime > 0 {
hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(decodedInfo.packet.rxTime)))
}
}
hopNodes.append(traceRouteHop)
let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "unknown".localized))
let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : ""
let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized
routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
}
let destinationHop = TraceRouteHopEntity(context: context)
destinationHop.name = traceRoute?.node?.user?.longName ?? "unknown".localized
destinationHop.time = Date()
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4
destinationHop.num = traceRoute?.node?.num ?? 0
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
destinationHop.altitude = mostRecent.altitude
destinationHop.latitudeI = mostRecent.latitudeI
destinationHop.longitudeI = mostRecent.longitudeI
traceRoute?.hasPositions = true
}
hopNodes.append(destinationHop)
/// Add the destination node to the end of the route towards string and the beginning of the route back string
routeString += "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)"
traceRoute?.routeText = routeString
traceRoute?.hopsBack = Int32(routingMessage.routeBack.count)
// Only if hopStart is set and there is an SNR entry
if decodedInfo.packet.hopStart > 0 && routingMessage.snrBack.count > 0 {
var routeBackString = "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> "
for (index, node) in routingMessage.routeBack.enumerated() {
var hopNode = getNodeInfo(id: Int64(node), context: context)
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
hopNode = createNodeInfo(num: Int64(node), context: context)
}
let traceRouteHop = TraceRouteHopEntity(context: context)
traceRouteHop.time = Date()
if routingMessage.snrTowards.count >= index + 1 {
traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4
traceRouteHop.back = true
if routingMessage.snrBack.count >= index + 1 {
traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4
} else {
// If no snr in route, set unknown
// If no snr in route, set to unknown
traceRouteHop.snr = -32
}
if let hn = hopNode, hn.hasPositions {
@ -894,69 +941,12 @@ class BLEManager: NSObject, CBPeripheralDelegate, MqttClientProxyManagerDelegate
let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "unknown".localized))
let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : ""
let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized
routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
}
let destinationHop = TraceRouteHopEntity(context: context)
destinationHop.name = traceRoute?.node?.user?.longName ?? "unknown".localized
destinationHop.time = Date()
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4
destinationHop.num = traceRoute?.node?.num ?? 0
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
destinationHop.altitude = mostRecent.altitude
destinationHop.latitudeI = mostRecent.latitudeI
destinationHop.longitudeI = mostRecent.longitudeI
traceRoute?.hasPositions = true
}
hopNodes.append(destinationHop)
/// Add the destination node to the end of the route towards string and the beginning of the route back string
routeString += "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)"
traceRoute?.routeText = routeString
traceRoute?.hopsBack = Int32(routingMessage.routeBack.count)
// Only if hopStart is set and there is an SNR entry
if decodedInfo.packet.hopStart > 0 && routingMessage.snrBack.count > 0 {
var routeBackString = "\(traceRoute?.node?.user?.longName ?? "unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> "
for (index, node) in routingMessage.routeBack.enumerated() {
var hopNode = getNodeInfo(id: Int64(node), context: context)
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
hopNode = createNodeInfo(num: Int64(node), context: context)
}
let traceRouteHop = TraceRouteHopEntity(context: context)
traceRouteHop.time = Date()
traceRouteHop.back = true
if routingMessage.snrBack.count >= index + 1 {
traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4
} else {
// If no snr in route, set to unknown
traceRouteHop.snr = -32
}
if let hn = hopNode, hn.hasPositions {
if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
traceRouteHop.altitude = mostRecent.altitude
traceRouteHop.latitudeI = mostRecent.latitudeI
traceRouteHop.longitudeI = mostRecent.longitudeI
traceRoute?.hasPositions = true
}
}
traceRouteHop.num = hopNode?.num ?? 0
if hopNode != nil {
if decodedInfo.packet.rxTime > 0 {
hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(decodedInfo.packet.rxTime)))
}
}
hopNodes.append(traceRouteHop)
let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "unknown".localized))
let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : ""
let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized
routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
}
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4
routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)"
traceRoute?.routeBackText = routeBackString
}
let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4
routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)"
traceRoute?.routeBackText = routeBackString
traceRoute?.hops = NSOrderedSet(array: hopNodes)
traceRoute?.time = Date()
do {

View file

@ -0,0 +1,84 @@
//
// SeriesConfigurationEntry.swift
// Meshtastic
//
// Created by Jake Bordens on 12/7/24.
//
import Charts
import OSLog
import SwiftUI
// MetricsTableColumn stores metadata about an attribute in TelemetryEntity.
// 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
// Heading for wider tables
let name: String
// Heading for space-constrained tables
let abbreviatedName: String
// Minimum/maximum grid width for this column
let minWidth: CGFloat?
let maxWidth: CGFloat?
// Recommended spacing, may be overridden
let spacing: CGFloat
// Should this column appear in the table
var visible: Bool
// Closure to render the table cell
let tableBodyClosure: (MetricsTableColumn, TelemetryEntity) -> AnyView?
// Main initializer
init<Value, TableContent: View>(
keyPath: KeyPath<TelemetryEntity, Value>,
name: String,
abbreviatedName: String,
minWidth: CGFloat? = nil,
maxWidth: CGFloat? = nil,
spacing: CGFloat = 0.1,
visible: Bool = true,
@ViewBuilder tableBody: @escaping (MetricsTableColumn, Value) -> TableContent?
) {
// This works because TelemetryEntity is an NSManagedObject and derrived from NSObject
self.attribute = NSExpression(forKeyPath: keyPath).keyPath
self.name = name
self.abbreviatedName = abbreviatedName
self.minWidth = minWidth
self.maxWidth = maxWidth
self.spacing = spacing
self.visible = visible
self.tableBodyClosure = { config, entity in
AnyView(tableBody(config, entity[keyPath: keyPath]))
}
}
var gridItemSize: GridItem.Size {
if let minWidth, let maxWidth {
return .flexible(minimum: minWidth, maximum: maxWidth)
}
return .flexible()
}
func body(_ te: TelemetryEntity) -> AnyView? {
return tableBodyClosure(self, te)
}
}
extension MetricsTableColumn: Identifiable, Hashable {
var id: String { self.attribute }
static func == (lhs: MetricsTableColumn, rhs: MetricsTableColumn) -> Bool {
lhs.attribute == rhs.attribute
}
func hash(into hasher: inout Hasher) {
hasher.combine(attribute)
}
}

View file

@ -0,0 +1,113 @@
//
// MetricsChartSeries.swift
// Meshtastic
//
// Created by Jake Bordens on 12/11/24.
//
import Charts
import Foundation
import SwiftUI
// MetricsChartSeries stores metadata about an attribute in TelemetryEntity.
// Given a keypath, this class holds information about how to render the attrbute in a
// the chart. MetricsChartSeries objects are collected in a MetricsSeriesList
class MetricsChartSeries: ObservableObject {
// CoreData Attribute Name on TelemetryEntity
let attribute: String
// Heading for areas that have the room
let name: String
// Heading for space-constrained areas
let abbreviatedName: String
// Should this column appear in the chart
var visible: Bool
// A closure that will provide the foreground style given the data set and overall chart range
let foregroundStyle: (ClosedRange<Float>?) -> AnyShapeStyle?
// A closure that will provide the Chart Content for this series
let chartBodyClosure:
(MetricsChartSeries, ClosedRange<Float>?, TelemetryEntity) -> AnyChartContent? // Closure to render the chart
// A closure that will privide the value of a TelemetryEntity for this series
// Possibly converted to the proper units
let valueClosure: (TelemetryEntity) -> Float?
// Main initializer
init<Value, ChartBody: ChartContent, ForegroundStyle: ShapeStyle>(
keyPath: KeyPath<TelemetryEntity, Value>,
name: String,
abbreviatedName: String,
conversion: ((Value) -> Value)? = nil,
visible: Bool = true,
foregroundStyle: @escaping ((ClosedRange<Float>?) -> ForegroundStyle?) = { _ in nil },
@ChartContentBuilder chartBody: @escaping (MetricsChartSeries, ClosedRange<Float>?, Date, Value) -> ChartBody?
) where Value: Plottable & Comparable {
// This works because TelemetryEntity is an NSManagedObject and derrived from NSObject
self.attribute = NSExpression(forKeyPath: keyPath).keyPath
self.name = name
self.abbreviatedName = abbreviatedName
self.visible = visible
// By saving these closures, MetricsChartSeries can be type agnostic
// This is a less elegant form of type erasure, but doesn't require a new Any-type
self.foregroundStyle = { range in foregroundStyle(range).map({ AnyShapeStyle($0) }) }
self.chartBodyClosure = { series, range, entity in
AnyChartContent(
chartBody(series, range, entity.time!, entity[keyPath: keyPath]))
}
self.valueClosure = { te in
if let conversion {
return conversion(te[keyPath: keyPath]).floatValue
}
return te[keyPath: keyPath].floatValue
}
}
// Return the value for this series attribute given a full row of telemetry data
func valueFor(_ te: TelemetryEntity) -> Float? {
return self.valueClosure(te)?.floatValue
}
// Return the chart content for this series given a full row of telemetry data
func body<T>(_ te: TelemetryEntity, inChartRange chartRange: ClosedRange<T>? = nil) -> AnyChartContent? where T: BinaryFloatingPoint {
let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) }
return chartBodyClosure(self, range, te)
}
}
extension MetricsChartSeries: Identifiable, Hashable {
var id: String { self.attribute }
static func == (lhs: MetricsChartSeries, rhs: MetricsChartSeries) -> Bool {
lhs.attribute == rhs.attribute
}
func hash(into hasher: inout Hasher) {
hasher.combine(attribute)
}
}
extension Plottable {
var floatValue: Float? {
if let integerValue = self.primitivePlottable as? any BinaryInteger {
return Float(integerValue)
} else if let floatingPointValue = self.primitivePlottable as? any BinaryFloatingPoint {
return Float(floatingPointValue)
}
return nil
}
var doubleValue: Double? {
if let integerValue = self.primitivePlottable as? any BinaryInteger {
return Double(integerValue)
} else if let floatingPointValue = self.primitivePlottable as? any BinaryFloatingPoint {
return Double(floatingPointValue)
}
return nil
}
}

View file

@ -0,0 +1,98 @@
//
// SeriesConfiguration.swift
// Meshtastic
//
// Created by Jake Bordens on 12/7/24.
//
import SwiftUI
class MetricsColumnList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection {
@Published var columns: [MetricsTableColumn]
init(columns: [MetricsTableColumn]) {
self.columns = columns
}
var visible: [MetricsTableColumn] {
return columns.filter { $0.visible }
}
func toggleVisibity(for column: MetricsTableColumn) {
if columns.contains(column) {
self.objectWillChange.send()
column.visible.toggle()
}
}
var gridItems: [GridItem] {
var returnValues: [GridItem] = []
let columnsInChart = self.visible
for i in 0..<columnsInChart.count {
let thisColumn = columnsInChart[i]
let spacing = (i == columns.count - 1) ? 0 : thisColumn.spacing
if let min = thisColumn.minWidth, let max = thisColumn.maxWidth {
returnValues.append(
GridItem(
.flexible(minimum: min, maximum: max), spacing: spacing)
)
} else {
returnValues.append(GridItem(.flexible(), spacing: spacing))
}
}
return returnValues
}
func column(forAttribute attribute: String) -> MetricsTableColumn? {
return columns.first(where: { $0.attribute == attribute})
}
// Collection conformance
typealias Index = Int
typealias Element = MetricsTableColumn
typealias SubSequence = ArraySlice<Element>
required init() { columns = [] }
required init<S: Sequence>(_ columns: S) where S.Element == Element {
self.columns = Array(columns)
}
var startIndex: Int { columns.startIndex }
var endIndex: Int { columns.endIndex }
subscript(position: Int) -> Element {
get { columns[position] }
set {
objectWillChange.send()
columns[position] = newValue
}
}
subscript(bounds: Range<Int>) -> ArraySlice<Element> { columns[bounds] }
func index(after i: Int) -> Int { columns.index(after: i) }
func replaceSubrange<C: Collection>(_ subrange: Range<Int>, with newElements: C) where C.Element == Element {
objectWillChange.send()
columns.replaceSubrange(subrange, with: newElements)
}
func append(_ newElement: Element) {
columns.append(newElement)
objectWillChange.send()
}
func remove(at index: Int) -> Element {
objectWillChange.send()
let removedElement = columns.remove(at: index)
return removedElement
}
func removeAll() {
objectWillChange.send()
columns.removeAll()
}
func insert(_ newElement: Element, at index: Int) {
objectWillChange.send()
columns.insert(newElement, at: index)
}
}

View file

@ -0,0 +1,109 @@
//
// MetricsChartSeriesList.swift
// Meshtastic
//
// Created by Jake Bordens on 12/11/24.
//
import Foundation
import SwiftUI
class MetricsSeriesList: ObservableObject, RandomAccessCollection, RangeReplaceableCollection {
@Published var series: [MetricsChartSeries]
var visible: [MetricsChartSeries] {
return series.filter { $0.visible }
}
func toggleVisibity(for aSeries: MetricsChartSeries) {
if series.contains(aSeries) {
self.objectWillChange.send()
aSeries.visible.toggle()
}
}
func foregroundStyle<T>(forName: String, chartRange: ClosedRange<T>? = nil) -> AnyShapeStyle? where T: BinaryFloatingPoint {
if let selectedSeries = series.first(where: { $0.name == forName }) {
let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) }
return selectedSeries.foregroundStyle(range)
}
return nil
}
func foregroundStyle<T>(forAbbreviatedName: String, chartRange: ClosedRange<T>? = nil) -> AnyShapeStyle? where T: BinaryFloatingPoint {
if let selectedSeries = series.first(where: { $0.abbreviatedName == forAbbreviatedName }) {
let range = chartRange.map { Float($0.lowerBound)...Float($0.upperBound) }
return selectedSeries.foregroundStyle(range)
}
return nil
}
func chartRange(forData data: [TelemetryEntity]) -> ClosedRange<Float> {
var lower: Float?
var upper: Float?
for te in data {
for aSeries in self.visible {
if let value = aSeries.valueFor(te) {
if value > (upper ?? -.infinity) {upper = value}
if value < (lower ?? .infinity) {lower = value}
}
}
}
// Return default range if no data or nil
guard let lower, let upper else {
return 0.0...100.0
}
return lower...upper
}
// Collection conformance
typealias Index = Int
typealias Element = MetricsChartSeries
typealias SubSequence = ArraySlice<Element>
required init() { series = [] }
required init<S: Sequence>(_ series: S) where S.Element == Element {
self.series = Array(series)
}
var startIndex: Int { series.startIndex }
var endIndex: Int { series.endIndex }
subscript(position: Int) -> Element {
get { series[position] }
set {
objectWillChange.send()
series[position] = newValue
}
}
subscript(bounds: Range<Int>) -> ArraySlice<Element> { series[bounds] }
func index(after i: Int) -> Int { series.index(after: i) }
func replaceSubrange<C: Collection>(_ subrange: Range<Int>, with newElements: C) where C.Element == Element {
objectWillChange.send()
series.replaceSubrange(subrange, with: newElements)
}
func append(_ newElement: Element) {
series.append(newElement)
objectWillChange.send()
}
func remove(at index: Int) -> Element {
objectWillChange.send()
let removedElement = series.remove(at: index)
return removedElement
}
func removeAll() {
objectWillChange.send()
series.removeAll()
}
func insert(_ newElement: Element, at index: Int) {
objectWillChange.send()
series.insert(newElement, at: index)
}
}

View file

@ -45,8 +45,9 @@ struct UserList: View {
NSSortDescriptor(key: "pkiEncrypted", ascending: false),
NSSortDescriptor(key: "userNode.lastHeard", ascending: false),
NSSortDescriptor(key: "longName", ascending: true)],
predicate: NSPredicate(format: "userNode.ignored == false && longName != ''"),
animation: .default
predicate: NSPredicate(
format: "userNode.ignored == false && longName != '' AND NOT (userNode.viaMqtt == YES AND userNode.hopsAway > 0)"
), animation: .default
)
var users: FetchedResults<UserEntity>
@ -297,7 +298,7 @@ struct UserList: View {
let textSearchPredicate = NSCompoundPredicate(type: .or, subpredicates: searchPredicates)
/// Create an array of predicates to hold our AND predicates
var predicates: [NSPredicate] = []
/// Mqtt
/// Mqtt and lora
if !(viaLora && viaMqtt) {
if viaLora {
let loraPredicate = NSPredicate(format: "userNode.viaMqtt == NO")
@ -307,9 +308,8 @@ struct UserList: View {
predicates.append(mqttPredicate)
}
} else {
/// Only show mqtt nodes that can be contacted (zero hops) on the default key
// let bothPredicate = NSPredicate(format: "userNode.viaMqtt == YES AND userNode.hopsAway == 0 OR userNode.viaMqtt == NO")
// predicates.append(bothPredicate)
let mqttPredicate = NSPredicate(format: "NOT (userNode.viaMqtt == YES AND userNode.hopsAway > 0)")
predicates.append(mqttPredicate)
}
/// Roles
if roleFilter && deviceRoles.count > 0 {

View file

@ -17,6 +17,11 @@ struct EnvironmentMetricsLog: View {
@State var exportString = ""
@ObservedObject var node: NodeInfoEntity
@StateObject var columnList = MetricsColumnList.environmentDefaultColumns
@StateObject var seriesList = MetricsSeriesList.environmentDefaultChartSeries
@State var isEditingColumnConfiguration = false
var body: some View {
VStack {
if node.hasEnvironmentMetrics {
@ -25,129 +30,70 @@ struct EnvironmentMetricsLog: View {
let chartData = environmentMetrics
.filter { $0.time != nil && $0.time! >= oneWeekAgo! }
.sorted { $0.time! < $1.time! }
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
let format: UnitTemperature = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? .fahrenheit : .celsius
let chartRange = applyMargins(seriesList.chartRange(forData: chartData))
VStack {
if chartData.count > 0 {
GroupBox(label: Label("\(environmentMetrics.count) Readings Total", systemImage: "chart.xyaxis.line")) {
Chart {
Chart(seriesList.visible) { series in
ForEach(chartData, id: \.time) { dataPoint in
AreaMark(
x: .value("Time", dataPoint.time!),
y: .value("Temperature", dataPoint.temperature.localeTemperature()),
stacking: .unstacked
)
.interpolationMethod(.cardinal)
.foregroundStyle(
.linearGradient(
colors: [.blue, .yellow, .orange, .red, .red],
startPoint: .bottom, endPoint: .top
)
.opacity(0.6)
)
.alignsMarkStylesWithPlotArea()
.accessibilityHidden(true)
LineMark(
x: .value("Time", dataPoint.time!),
y: .value("Temperature", dataPoint.temperature.localeTemperature())
)
.interpolationMethod(.cardinal)
.foregroundStyle(
.linearGradient(
colors: [.blue, .yellow, .orange, .red, .red],
startPoint: .bottom, endPoint: .top
)
)
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
series.body(dataPoint, inChartRange: chartRange)
}
}
.chartXAxis(content: {
AxisMarks(position: .top)
})
.chartYScale(domain: format == .celsius ? -20...55 : 0...125)
.chartForegroundStyleScale([
"Temperature": .clear
])
.chartYScale(domain: chartRange)
.chartForegroundStyleScale { (seriesName: String) -> AnyShapeStyle in
return seriesList.foregroundStyle(forAbbreviatedName: seriesName, chartRange: chartRange) ?? AnyShapeStyle(Color.clear)
}
.chartLegend(position: .automatic, alignment: .bottom)
}
}
let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMMddjmma", options: 0, locale: Locale.current)
let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mma").replacingOccurrences(of: ",", with: "")
// Dynamic table column using SwiftUI Table requires TableColumnForEach which requires the target
// to be bumped to 17.4 -- Until that happens, the existing non-configurable table is used.
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
// Add a table for mac and ipad
Table(environmentMetrics) {
TableColumn("Temperature") { em in
Text(em.temperature.formattedTemperature())
columnList.column(forAttribute: "temperature")?.body(em)
}
TableColumn("Humidity") { em in
Text("\(String(format: "%.0f", em.relativeHumidity))%")
columnList.column(forAttribute: "relativeHumidity")?.body(em)
}
TableColumn("Barometric Pressure") { em in
Text("\(String(format: "%.1f", em.barometricPressure)) hPa")
columnList.column(forAttribute: "barometricPressure")?.body(em)
}
TableColumn("Indoor Air Quality") { em in
HStack {
Text("IAQ")
IndoorAirQuality(iaq: Int(em.iaq), displayMode: IaqDisplayMode.dot )
}
columnList.column(forAttribute: "iaq")?.body(em)
}
TableColumn("Wind Speed") { em in
let windSpeed = Measurement(value: Double(em.windSpeed), unit: UnitSpeed.kilometersPerHour)
Text(windSpeed.formatted(.measurement(width: .abbreviated, numberFormatStyle: .number.precision(.fractionLength(0)))))
columnList.column(forAttribute: "windSpeed")?.body(em)
}
TableColumn("Wind Direction") { em in
let direction = cardinalValue(from: Double(em.windDirection))
Text(direction)
columnList.column(forAttribute: "windDirection")?.body(em)
}
TableColumn("timestamp") { em in
Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized)
columnList.column(forAttribute: "time")?.body(em)
}
.width(min: 180)
}
} else {
ScrollView {
let columns = [
GridItem(.flexible(minimum: 30, maximum: 50), spacing: 0.1),
GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1),
GridItem(.flexible(minimum: 30, maximum: 60), spacing: 0.1),
GridItem(.flexible(minimum: 30, maximum: 70), spacing: 0.1),
GridItem(spacing: 0)
]
LazyVGrid(columns: columns, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) {
LazyVGrid(columns: columnList.gridItems, alignment: .leading, spacing: 1, pinnedViews: [.sectionHeaders]) {
GridRow {
Text("Temp")
.font(.caption)
.fontWeight(.bold)
Text("Hum")
.font(.caption)
.fontWeight(.bold)
Text("Bar")
.font(.caption)
.fontWeight(.bold)
Text("IAQ")
.font(.caption)
.fontWeight(.bold)
Text("timestamp")
.font(.caption)
.fontWeight(.bold)
ForEach(columnList.visible) { col in
Text(col.abbreviatedName)
.font(.caption)
.fontWeight(.bold)
}
}
ForEach(environmentMetrics, id: \.self) { em in
GridRow {
Text(em.temperature.formattedTemperature())
.font(.caption)
Text("\(String(format: "%.0f", em.relativeHumidity))%")
.font(.caption)
Text("\(String(format: "%.1f", em.barometricPressure))")
.font(.caption)
IndoorAirQuality(iaq: Int(em.iaq), displayMode: .dot)
.font(.caption)
Text(em.time?.formattedDate(format: dateFormatString) ?? "unknown.age".localized)
.font(.caption)
ForEach(columnList.visible) { col in
col.body(em)
.font(.caption)
}
}
}
}
@ -157,17 +103,33 @@ struct EnvironmentMetricsLog: View {
}
}
HStack {
let isPadOrCatalyst = UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac
let buttonSize: ControlSize = isPadOrCatalyst ? .large : .small
let imageScale: Image.Scale = isPadOrCatalyst ? .medium : .small
Button {
self.isEditingColumnConfiguration = true
} label: {
Label("Config", systemImage: "tablecells")
.imageScale(imageScale)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(buttonSize)
.padding(.bottom)
.padding(.leading)
.sheet(isPresented: self.$isEditingColumnConfiguration) {
MetricsColumnDetail(columnList: columnList, seriesList: seriesList)
}
Button(role: .destructive) {
isPresentingClearLogConfirm = true
} label: {
Label("clear.log", systemImage: "trash.fill")
.imageScale(imageScale)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.controlSize(buttonSize)
.padding(.bottom)
.padding(.leading)
.confirmationDialog(
"are.you.sure",
isPresented: $isPresentingClearLogConfirm,
@ -184,10 +146,11 @@ struct EnvironmentMetricsLog: View {
isExporting = true
} label: {
Label("save", systemImage: "square.and.arrow.down")
.imageScale(imageScale)
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.controlSize(buttonSize)
.padding(.bottom)
.padding(.trailing)
}
@ -219,4 +182,13 @@ struct EnvironmentMetricsLog: View {
}
)
}
// Helper. Adds a little buffer to the Y axis range, but keeps Y=0
func applyMargins<T>(_ range: ClosedRange<T>) -> ClosedRange<T> where T: BinaryFloatingPoint {
let span = range.upperBound - range.lowerBound
let margin = span * 0.1
let lower = range.lowerBound == 0.0 ? 0.0 : range.lowerBound - margin
let upper = range.upperBound + margin
return lower...upper
}
}

View file

@ -10,7 +10,7 @@ struct IgnoreNodeButton: View {
var node: NodeInfoEntity
var body: some View {
Button {
Button(role: .destructive) {
guard let connectedNodeNum = bleManager.connectedPeripheral?.num else { return }
let success = if node.ignored {
bleManager.removeIgnoredNode(

View file

@ -143,7 +143,7 @@ struct PositionPopover: View {
/// Heading
let degrees = Angle.degrees(Double(position.heading))
Label {
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees)
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees).reciprocal()
Text("Heading: \(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))")
} icon: {
Image(systemName: "location.north")

View file

@ -0,0 +1,216 @@
//
// EnvironmentDefaultSeries.swift
// Meshtastic
//
// Created by Jake Bordens on 12/11/24.
//
import Charts
import Foundation
import SwiftUI
// This is the default configuration used by the EnvironmentMetricsLog view for the chart
extension MetricsSeriesList {
static var environmentDefaultChartSeries: MetricsSeriesList {
MetricsSeriesList([
// Temperature Series Configuration
MetricsChartSeries(
keyPath: \.temperature,
name: "Temperature",
abbreviatedName: "Temp",
conversion: { Float($0.localeTemperature()) },
foregroundStyle: { chartRange in
let locale = NSLocale.current as NSLocale
let localeUnit = locale.object(forKey: NSLocale.Key(rawValue: "kCFLocaleTemperatureUnitKey"))
let format: UnitTemperature = localeUnit as? String ?? "Celsius" == "Fahrenheit" ? .fahrenheit : .celsius
let lowerBound = chartRange.map { Double($0.lowerBound) } ?? 0.0
let upperBound = chartRange.map { Double($0.upperBound) } ?? 100.0
let stops: [Gradient.Stop] = generateStops(minTemp: lowerBound, maxTemp: upperBound, tempUnit: format, opacity: 1.0)
return LinearGradient(stops: stops, startPoint: .bottom, endPoint: .top)
},
chartBody: { series, chartRange, time, temperature in
AreaMark(
x: .value("Time", time),
yStart: .value(series.abbreviatedName, chartRange?.lowerBound.doubleValue ?? 0.0),
yEnd: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.alignsMarkStylesWithPlotArea()
.accessibilityHidden(true)
.opacity(0.6)
LineMark(
x: .value("Time", time),
y: .value(
series.abbreviatedName, temperature.localeTemperature())
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}),
// Relative Humidity Series Configuration
MetricsChartSeries(
keyPath: \.relativeHumidity,
name: "Relative Humidity",
abbreviatedName: "Hum",
foregroundStyle: { _ in
.linearGradient(
colors: [Color(UIColor.purple.darker(componentDelta: 0.2)), .purple],
startPoint: .bottom, endPoint: .top
)
},
chartBody: { series, _, time, humidity in
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, humidity)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}),
// Barometric Pressure Series Configuration
MetricsChartSeries(
keyPath: \.barometricPressure,
name: "Barometric Pressure",
abbreviatedName: "Bar",
visible: false,
foregroundStyle: { _ in
.linearGradient(
colors: [Color(UIColor.green.darker(componentDelta: 0.3)), .green],
startPoint: .bottom, endPoint: .top
)
},
chartBody: { series, _, time, pressure in
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, pressure)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
}),
// Indoor Air Quality Series Configuration
MetricsChartSeries(
keyPath: \.iaq,
name: "Indoor Air Quality",
abbreviatedName: "IAQ",
visible: false,
foregroundStyle: { _ in .gray },
chartBody: { series, _, time, iaq in
let iaqEnum = Iaq.getIaq(for: Int(iaq))
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.symbol(Circle())
.foregroundStyle(iaqEnum.color)
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, Float(iaq))
)
.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,
name: "Wind Speed/Direction",
abbreviatedName: "Speed/Dir",
visible: false,
foregroundStyle: { _ in
.linearGradient(
colors: [Color(UIColor.yellow.darker(componentDelta: 0.3)), Color(UIColor.yellow.darker(componentDelta: 0.1))],
startPoint: .bottom, endPoint: .top
)
},
chartBody: { series, _, time, wsad in
// debug data: var wsad = WindSpeedAndDirection(windSpeed:Float.random(in:0...25), windDirection: Int32.random(in:0..<3)*90 )
LineMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.interpolationMethod(.catmullRom)
.foregroundStyle(by: .value("Series", series.abbreviatedName))
.lineStyle(StrokeStyle(lineWidth: 4))
.alignsMarkStylesWithPlotArea()
PointMark(
x: .value("Time", time),
y: .value(series.abbreviatedName, wsad.windSpeed)
)
.symbol {
Image(systemName: "location.north.circle.fill")
.symbolRenderingMode(.palette)
.foregroundStyle(Color.white, Color(UIColor.yellow.darker(componentDelta: 0.3)))
.rotationEffect(
.degrees(Double(wsad.windDirection)))
}.foregroundStyle(.yellow)
})
])
}
}
// Extension to combine windspeed and direction into one attribute for rendering
// for rendering on the chart.
@objc class WindSpeedAndDirection: NSObject, Plottable, Comparable {
let windSpeed: Float
let windDirection: Int32
init(windSpeed: Float, windDirection: Int32) {
self.windSpeed = windSpeed
self.windDirection = windDirection
}
// Plottable Conformance
required init?(primitivePlottable: Float) { nil }
var primitivePlottable: Float { windSpeed }
static func < (lhs: WindSpeedAndDirection, rhs: WindSpeedAndDirection) -> Bool {
lhs.windSpeed < rhs.windSpeed
}
}
@objc extension TelemetryEntity {
var windSpeedAndDirection: WindSpeedAndDirection {
return WindSpeedAndDirection(
windSpeed: self.windSpeed, windDirection: self.windDirection)
}
}
// From: https://github.com/meshtastic/Meshtastic-Apple/pull/1013/commits/bc932567c742c8fa9fd30752237b10cb762c5ef3
// Set up gradient stops relative to the scale of the temperature chart
func generateStops(minTemp: Double, maxTemp: Double, tempUnit: UnitTemperature, opacity: Double) -> [Gradient.Stop] {
var gradientStops = [Gradient.Stop]()
let stopTargets: [(Double, Color)] = [
((tempUnit == .celsius ? 0 : 32), .blue),
((tempUnit == .celsius ? 20 : 68), .yellow),
((tempUnit == .celsius ? 30 : 86), .orange),
((tempUnit == .celsius ? 55 : 125), .red)
]
for (stopValue, color) in stopTargets {
let stopLocation = transform(stopValue, from: minTemp...maxTemp, to: 0...1)
gradientStops.append(Gradient.Stop(color: color.opacity(opacity), location: stopLocation))
}
return gradientStops
}
// Map inputRange to outputRange
func transform<T: FloatingPoint>(_ input: T, from inputRange: ClosedRange<T>, to outputRange: ClosedRange<T>) -> T {
// need to determine what that value would be in (to.low, to.high)
// difference in output range / difference in input range = slope
let slope = (outputRange.upperBound - outputRange.lowerBound) / (inputRange.upperBound - inputRange.lowerBound)
// slope * normalized input + output lower
let output = slope * (input - inputRange.lowerBound) + outputRange.lowerBound
return output
}

View file

@ -0,0 +1,123 @@
//
// EnvironmentDefaultColumns.swift
// Meshtastic
//
// Created by Jake Bordens on 12/10/24.
//
import Charts
import Foundation
import SwiftUI
// This is the default configuration used by the EnvironmentMetricsLog view for the table
extension MetricsColumnList {
static var environmentDefaultColumns: MetricsColumnList {
MetricsColumnList(columns: [
// Temperature Series Configuration
MetricsTableColumn(
keyPath: \.temperature,
name: "Temperature",
abbreviatedName: "Temp",
minWidth: 30, maxWidth: 45,
tableBody: { _, temp in
Text(temp.formattedTemperature())
}),
// Relative Humidity Series Configuration
MetricsTableColumn(
keyPath: \.relativeHumidity,
name: "Relative Humidity",
abbreviatedName: "Hum",
minWidth: 30, maxWidth: 45,
tableBody: { _, humidity in
Text("\(String(format: "%.0f", humidity))%")
}),
// Barometric Pressure Series Configuration
MetricsTableColumn(
keyPath: \.barometricPressure,
name: "Barometric Pressure",
abbreviatedName: "Bar",
minWidth: 30, maxWidth: 50,
tableBody: { _, pressure in
if (UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) {
Text("\(String(format: "%.1f hPa", pressure))")
} else {
Text("\(String(format: "%.1f", pressure))")
}
}),
// 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)
}),
// Wind Direction Series Configuration
MetricsTableColumn(
keyPath: \.windDirection,
name: "Wind Direction",
abbreviatedName: "Dir",
minWidth: 30, maxWidth: 40,
visible: false,
tableBody: { _, wind in
HStack(spacing: 1.0) {
// debug data: let wind = Double.random(in: 0..<360.0)
let wind = Double(wind)
Image(systemName: "location.north")
.imageScale(.small)
.scaleEffect(0.9, anchor: .center)
.rotationEffect(.degrees(wind))
.foregroundStyle(.blue)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
Text(cardinalValue(from: wind))
} else {
Text(abbreviatedCardinalValue(from: wind))
}
}
}),
// Wind Speed Series Configuration
MetricsTableColumn(
keyPath: \.windSpeed,
name: "Wind Speed",
abbreviatedName: "Wind",
minWidth: 30, maxWidth: 60,
visible: false,
tableBody: { _, speed in
let windSpeed = Measurement(
value: Double(speed), unit: UnitSpeed.kilometersPerHour)
Text(
windSpeed.formatted(
.measurement(
width: .abbreviated,
numberFormatStyle: .number.precision(
.fractionLength(0))))
)
}),
// Timestamp Series Configuration -- for use in table only
MetricsTableColumn(
keyPath: \.time,
name: "Timestamp",
abbreviatedName: "Time",
minWidth: 140.0, maxWidth: 2000.0,
tableBody: { _, time in
let localeDateFormat = DateFormatter.dateFormat(
fromTemplate: "yyMMddjmma", options: 0,
locale: Locale.current)
let dateFormatString =
(localeDateFormat ?? "MM/dd/YY j:mma")
.replacingOccurrences(of: ",", with: "")
Text(
time?.formattedDate(format: dateFormatString)
?? "unknown.age".localized
)
})
])
}
}

View file

@ -0,0 +1,80 @@
//
// MetricsColumnDetail.swift
// Meshtastic
//
// Created by Jake Bordens on 12/10/24.
//
import SwiftUI
struct MetricsColumnDetail: View {
@ObservedObject var columnList: MetricsColumnList
@ObservedObject var seriesList: MetricsSeriesList
@State private var currentDetent = PresentationDetent.medium
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section("Chart") {
ForEach(seriesList) { series in
HStack {
Circle()
.fill(series.foregroundStyle(0.0...100.0) ?? AnyShapeStyle(.clear))
.frame(width: 20.0, height: 20.0)
Text(series.name)
Spacer()
if series.visible {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}.contentShape(Rectangle()) // Ensures the entire row is tappable
.onTapGesture {
seriesList.toggleVisibity(for: series)
}
}
}
// Dynamic table column using SwiftUI Table requires TableColumnForEach which requires the target
// to be bumped to 17.4 -- Until that happens, the existing non-configurable table is used.
if !(UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac) {
Section("Table") {
ForEach(columnList.columns) { column in
HStack {
Text(column.name)
Spacer()
if column.visible {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}.contentShape(Rectangle()) // Ensures the entire row is tappable
.onTapGesture {
columnList.objectWillChange.send()
columnList.toggleVisibity(for: column)
}
}
}
}
}
.listStyle(.insetGrouped)
#if targetEnvironment(macCatalyst)
Spacer()
Button {
dismiss()
} label: {
Label("close", systemImage: "xmark")
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.large)
.padding(.bottom)
#endif
}
.presentationDetents([.medium, .large], selection: $currentDetent)
.presentationContentInteraction(.scrolls)
.presentationDragIndicator(.visible)
.presentationBackgroundInteraction(.enabled(upThrough: .medium))
.interactiveDismissDisabled(false)
}
}

View file

@ -357,6 +357,11 @@ struct NodeDetail: View {
node: node
)
}
IgnoreNodeButton(
bleManager: bleManager,
context: context,
node: node
)
DeleteNodeButton(
bleManager: bleManager,
context: context,
@ -465,3 +470,28 @@ func cardinalValue(from heading: Double) -> String {
return ""
}
}
func abbreviatedCardinalValue(from heading: Double) -> String {
switch heading {
case 0 ..< 22.5:
return "N"
case 22.5 ..< 67.5:
return "NE"
case 67.5 ..< 112.5:
return "E"
case 112.5 ..< 157.5:
return "E"
case 157.5 ..< 202.5:
return "S"
case 202.5 ..< 247.5:
return "SW"
case 247.5 ..< 292.5:
return "W"
case 292.5 ..< 337.5:
return "NW"
case 337.5 ... 360.0:
return "N"
default:
return ""
}
}

View file

@ -120,7 +120,7 @@ struct NodeListItem: View {
.symbolRenderingMode(.multicolor)
.clipShape(Circle())
.rotationEffect(headingDegrees)
let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees)
let heading = Measurement(value: trueBearing, unit: UnitAngle.degrees).reciprocal()
Text("\(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))")
.font(UIDevice.current.userInterfaceIdiom == .phone ? .callout : .caption)
.foregroundColor(.gray)

View file

@ -52,7 +52,7 @@ struct PositionLog: View {
}
TableColumn("Heading") { position in
let degrees = Angle.degrees(Double(position.heading))
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees)
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees).reciprocal()
Text(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))
}
TableColumn("SNR") { position in

View file

@ -55,7 +55,7 @@ struct TraceRouteLog: View {
.font(.caption)
}
} icon: {
Image(systemName: route.response ? (route.hops?.count == 0 && route.response ? "person.line.dotted.person" : "point.3.connected.trianglepath.dotted") : "person.slash")
Image(systemName: route.response ? (route.hopsTowards == 0 && route.response ? "person.line.dotted.person" : "point.3.connected.trianglepath.dotted") : "person.slash")
.symbolRenderingMode(.hierarchical)
}
.swipeActions {
@ -76,15 +76,7 @@ struct TraceRouteLog: View {
Divider()
ScrollView {
if selectedRoute != nil {
if selectedRoute?.response ?? false && selectedRoute?.hopsTowards ?? 0 == 0 {
Label {
Text("Trace route received directly by \(selectedRoute?.node?.user?.longName ?? "unknown".localized) with a SNR of \(String(format: "%.2f", selectedRoute?.snr ?? 0.0)) dB")
} icon: {
Image(systemName: "signpost.right.and.left")
.symbolRenderingMode(.hierarchical)
}
.font(.title3)
} else if selectedRoute?.response ?? false && selectedRoute?.hopsTowards ?? 0 > 0 {
if selectedRoute?.response ?? false && selectedRoute?.hopsTowards ?? 0 >= 0 {
Label {
Text("Route: \(selectedRoute?.routeText ?? "unknown".localized)")
} icon: {

View file

@ -22,7 +22,7 @@ struct GPSStatus: View {
let altitiude = Measurement(value: newLocation.altitude, unit: UnitLength.meters)
let speed = Measurement(value: newLocation.speed, unit: UnitSpeed.kilometersPerHour)
let speedAccuracy = Measurement(value: newLocation.speedAccuracy, unit: UnitSpeed.metersPerSecond)
let courseAccuracy = Measurement(value: newLocation.courseAccuracy, unit: UnitAngle.degrees)
let courseAccuracy = Measurement(value: newLocation.courseAccuracy, unit: UnitAngle.degrees).reciprocal()
Label("Coordinate \(String(format: "%.5f", newLocation.coordinate.latitude)), \(String(format: "%.5f", newLocation.coordinate.longitude))", systemImage: "mappin")
.font(largeFont)
@ -45,7 +45,7 @@ struct GPSStatus: View {
HStack {
let degrees = Angle.degrees(newLocation.course)
Label {
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees)
let heading = Measurement(value: degrees.degrees, unit: UnitAngle.degrees).reciprocal()
Text("Heading: \(heading.formatted(.measurement(width: .narrow, numberFormatStyle: .number.precision(.fractionLength(0)))))")
} icon: {
Image(systemName: "location.north")