diff --git a/Localizable.xcstrings b/Localizable.xcstrings index ca7d895e0..a5e013569 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -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" : { diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index d767a83c8..8877bfa99 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -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 = ""; }; + 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 = ""; }; + 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 = ""; }; 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 = ""; }; @@ -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 = ""; + }; + 231B3F232D087C020069A07D /* Metrics Columns */ = { + isa = PBXGroup; + children = ( + 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */, + 2373AE162D0A26620086C749 /* EnviornmentDefaultSeries.swift */, + 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */, + ); + path = "Metrics Columns"; + sourceTree = ""; + }; 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 = ""; diff --git a/Meshtastic/Extensions/Measurement.swift b/Meshtastic/Extensions/Measurement.swift index ca867c09a..4947c823d 100644 --- a/Meshtastic/Extensions/Measurement.swift +++ b/Meshtastic/Extensions/Measurement.swift @@ -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 { var measurement: Measurement } diff --git a/Meshtastic/Helpers/BLEManager.swift b/Meshtastic/Helpers/BLEManager.swift index 5484f74a0..2bba29c73 100644 --- a/Meshtastic/Helpers/BLEManager.swift +++ b/Meshtastic/Helpers/BLEManager.swift @@ -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 + } + 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 + } } - var routeString = "\(connectedNode.user?.longName ?? "???") --> " - hopNodes.append(connectedHop) - traceRoute?.hopsTowards = Int32(routingMessage.route.count) - for (index, node) in routingMessage.route.enumerated() { + 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 { diff --git a/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift new file mode 100644 index 000000000..188e4eba7 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricTableColumn.swift @@ -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( + keyPath: KeyPath, + 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) + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift new file mode 100644 index 000000000..add0318e7 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricsChartSeries.swift @@ -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?) -> AnyShapeStyle? + + // A closure that will provide the Chart Content for this series + let chartBodyClosure: + (MetricsChartSeries, ClosedRange?, 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( + keyPath: KeyPath, + name: String, + abbreviatedName: String, + conversion: ((Value) -> Value)? = nil, + 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.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(_ te: TelemetryEntity, inChartRange chartRange: ClosedRange? = 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 + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift new file mode 100644 index 000000000..0476b6b89 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricsColumnList.swift @@ -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.. MetricsTableColumn? { + return columns.first(where: { $0.attribute == attribute}) + } + + // Collection conformance + typealias Index = Int + typealias Element = MetricsTableColumn + typealias SubSequence = ArraySlice + + required init() { columns = [] } + required init(_ 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) -> ArraySlice { columns[bounds] } + func index(after i: Int) -> Int { columns.index(after: i) } + + func replaceSubrange(_ subrange: Range, 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) + } +} diff --git a/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift new file mode 100644 index 000000000..049d1fb46 --- /dev/null +++ b/Meshtastic/Model/Metrics Visualization/MetricsSeriesList.swift @@ -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(forName: String, chartRange: ClosedRange? = 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(forAbbreviatedName: String, chartRange: ClosedRange? = 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 { + 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 + + required init() { series = [] } + required init(_ 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) -> ArraySlice { series[bounds] } + func index(after i: Int) -> Int { series.index(after: i) } + + func replaceSubrange(_ subrange: Range, 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) + } + +} diff --git a/Meshtastic/Views/Messages/UserList.swift b/Meshtastic/Views/Messages/UserList.swift index 0496a51e1..a89862507 100644 --- a/Meshtastic/Views/Messages/UserList.swift +++ b/Meshtastic/Views/Messages/UserList.swift @@ -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 @@ -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 { diff --git a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift index 56b0c82c3..b7d5449c8 100644 --- a/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift +++ b/Meshtastic/Views/Nodes/EnvironmentMetricsLog.swift @@ -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(_ range: ClosedRange) -> ClosedRange 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 + } } diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift index 0ec051642..84fdf4d3d 100644 --- a/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift +++ b/Meshtastic/Views/Nodes/Helpers/Actions/IgnoreNodeButton.swift @@ -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( diff --git a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift index fb019e0b3..8874ba982 100644 --- a/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift +++ b/Meshtastic/Views/Nodes/Helpers/Map/PositionPopover.swift @@ -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") diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift new file mode 100644 index 000000000..810eaab77 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnviornmentDefaultSeries.swift @@ -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(_ input: T, from inputRange: ClosedRange, to outputRange: ClosedRange) -> 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 +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift new file mode 100644 index 000000000..7df0dbd3f --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/EnvironmentDefaultColumns.swift @@ -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 + ) + }) + ]) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift new file mode 100644 index 000000000..1f384cb24 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Metrics Columns/MetricsColumnDetail.swift @@ -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) + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index d9fc50a4e..706b9d6ec 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -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 "" + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift index b75d5822b..2693f13e0 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeListItem.swift @@ -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) diff --git a/Meshtastic/Views/Nodes/PositionLog.swift b/Meshtastic/Views/Nodes/PositionLog.swift index 3abcb791d..ee2709db0 100644 --- a/Meshtastic/Views/Nodes/PositionLog.swift +++ b/Meshtastic/Views/Nodes/PositionLog.swift @@ -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 diff --git a/Meshtastic/Views/Nodes/TraceRouteLog.swift b/Meshtastic/Views/Nodes/TraceRouteLog.swift index dd4223f72..1bdccf460 100644 --- a/Meshtastic/Views/Nodes/TraceRouteLog.swift +++ b/Meshtastic/Views/Nodes/TraceRouteLog.swift @@ -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: { diff --git a/Meshtastic/Views/Settings/GPSStatus.swift b/Meshtastic/Views/Settings/GPSStatus.swift index c92a647c7..7e0a6588e 100644 --- a/Meshtastic/Views/Settings/GPSStatus.swift +++ b/Meshtastic/Views/Settings/GPSStatus.swift @@ -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")