diff --git a/.gitmodules b/.gitmodules index e6f376a0..76fe28bf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "protobufs"] path = protobufs - url = https://github.com/meshtastic/protobufs.git + url = https://github.com/RCGV1/protobufs-fork.git + branch = noise-floor diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 33be032d..a45ef70f 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -719,6 +719,10 @@ } } }, + "%@ dBm" : { + "comment" : "A text view displaying the noise floor of a local stats entry, formatted to one decimal place. The argument is the noise floor value.", + "isCommentAutoGenerated" : true + }, "%@, %@" : { "localizations" : { "en" : { @@ -1927,6 +1931,10 @@ } } }, + "A local stats request has been sent to %@. Responses can some time." : { + "comment" : "An alert message explaining that a local stats request has been sent to a specific node. The placeholder inside the parentheses should be replaced with the name of the node.", + "isCommentAutoGenerated" : true + }, "A Meshtastic QR code contains the LoRa config and channel values needed for radios to communicate. You can share a complete channel configuration using the Replace Channels option, if you choose Add Channels your shared channels will be added to the channels on the receiving radio." : { "localizations" : { "de" : { @@ -4707,6 +4715,10 @@ } } }, + "Bad Rx" : { + "comment" : "A column in the local stats table that shows the number of received packets that were not valid.", + "isCommentAutoGenerated" : true + }, "Bandwidth" : { "localizations" : { "de" : { @@ -6081,6 +6093,12 @@ } } } + }, + "Canceled" : { + + }, + "Canceled: %d" : { + }, "Canned Message module config received: %@" : { "localizations" : { @@ -9847,6 +9865,10 @@ } } }, + "Delete all local stats?" : { + "comment" : "A confirmation dialog title that asks if the user is sure they want to delete all local stats.", + "isCommentAutoGenerated" : true + }, "Delete all pax data?" : { "localizations" : { "it" : { @@ -12371,6 +12393,12 @@ } } } + }, + "Dupes" : { + + }, + "Dupes: %d" : { + }, "Easily set up private mesh networks for secure and reliable communication in remote areas." : { "localizations" : { @@ -17860,6 +17888,10 @@ } } }, + "Icky" : { + "comment" : "\"Icky\" is a slang term for \"very bad\" or \"horrible\".", + "isCommentAutoGenerated" : true + }, "Icon" : { "localizations" : { "de" : { @@ -19361,6 +19393,20 @@ } } }, + "Local Stats" : { + + }, + "Local Stats (in %llds)" : { + "comment" : "A label that appears in the button while a local stats request is in progress. The number in parentheses is replaced with the remaining time in seconds.", + "isCommentAutoGenerated" : true + }, + "Local Stats Log" : { + + }, + "Local Stats Requested" : { + "comment" : "The title of an alert that appears when a user successfully requests a local stats update.", + "isCommentAutoGenerated" : true + }, "Location:" : { "localizations" : { "de" : { @@ -23243,6 +23289,10 @@ } } }, + "No Local Stats" : { + "comment" : "A message indicating that there are no local statistics available.", + "isCommentAutoGenerated" : true + }, "No map data files uploaded" : { "comment" : "Message when no files are uploaded", "localizations" : { @@ -23413,6 +23463,9 @@ } } } + }, + "No Reading" : { + }, "No Response" : { "localizations" : { @@ -23901,6 +23954,19 @@ } } } + }, + "Nodes Online" : { + "comment" : "A label describing how many nodes are currently online.", + "isCommentAutoGenerated" : true + }, + "Noise Floor" : { + + }, + "Noise Floor %@ dBm" : { + + }, + "Noise Floor No Reading" : { + }, "None" : { "localizations" : { @@ -25365,6 +25431,14 @@ } } }, + "Packets Rx" : { + "comment" : "A column header for the number of received packets.", + "isCommentAutoGenerated" : true + }, + "Packets Tx" : { + "comment" : "A column in the local stats table showing the number of packets transmitted.", + "isCommentAutoGenerated" : true + }, "Pairing Mode" : { "localizations" : { "de" : { @@ -28814,6 +28888,9 @@ } } } + }, + "Relayed" : { + }, "Relayed by %d %@" : { "localizations" : { @@ -28824,6 +28901,9 @@ } } } + }, + "Relayed: %d" : { + }, "Release Notes" : { "localizations" : { @@ -29199,6 +29279,10 @@ } } }, + "Request Local Stats" : { + "comment" : "A button label that requests a local stats request.", + "isCommentAutoGenerated" : true + }, "Request PKI Admin: %@" : { "localizations" : { "it" : { @@ -42321,4 +42405,4 @@ } }, "version" : "1.1" -} +} \ No newline at end of file diff --git a/Meshtastic.xcodeproj/project.pbxproj b/Meshtastic.xcodeproj/project.pbxproj index b87ce5f2..fba8c75c 100644 --- a/Meshtastic.xcodeproj/project.pbxproj +++ b/Meshtastic.xcodeproj/project.pbxproj @@ -104,6 +104,8 @@ BCB613832C672A2600485544 /* MessageChannelIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613822C672A2600485544 /* MessageChannelIntent.swift */; }; BCB613852C68703800485544 /* NodePositionIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613842C68703800485544 /* NodePositionIntent.swift */; }; BCB613872C69A0FB00485544 /* AppIntentErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCB613862C69A0FB00485544 /* AppIntentErrors.swift */; }; + BCCA0BAB2F1C5C25007648E5 /* LocalStatsLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */; }; + BCCA0BAD2F1C5C60007648E5 /* RequestLocalStatsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */; }; BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */; }; BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */; }; BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */; }; @@ -416,6 +418,8 @@ BCB613822C672A2600485544 /* MessageChannelIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageChannelIntent.swift; sourceTree = ""; }; BCB613842C68703800485544 /* NodePositionIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodePositionIntent.swift; sourceTree = ""; }; BCB613862C69A0FB00485544 /* AppIntentErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppIntentErrors.swift; sourceTree = ""; }; + BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalStatsLog.swift; sourceTree = ""; }; + BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestLocalStatsButton.swift; sourceTree = ""; }; BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactURLHandler.swift; sourceTree = ""; }; BCD93CB92D9E11A2006C9214 /* DisconnectNodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisconnectNodeIntent.swift; sourceTree = ""; }; BCDDFA992DBB180D0065189C /* ScrollToBottomButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToBottomButton.swift; sourceTree = ""; }; @@ -844,6 +848,7 @@ 251926882C3BAF2E00249DF5 /* Actions */ = { isa = PBXGroup; children = ( + BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */, DDDFE73E2D0D48FF0044463C /* IgnoreNodeButton.swift */, 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */, 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */, @@ -932,6 +937,7 @@ DD47E3CA26F0E50300029299 /* Nodes */ = { isa = PBXGroup; children = ( + BCCA0BAA2F1C5C25007648E5 /* LocalStatsLog.swift */, DDDB26402AABEF7B003AFCB7 /* Helpers */, DDDB263E2AABEE20003AFCB7 /* NodeList.swift */, DD769E0228D18BF0001A3F05 /* DeviceMetricsLog.swift */, @@ -1654,6 +1660,7 @@ 237AEB932E1FE4BA003B7CE3 /* Connection.swift in Sources */, DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */, 237AEB952E1FE516003B7CE3 /* Device.swift in Sources */, + BCCA0BAD2F1C5C60007648E5 /* RequestLocalStatsButton.swift in Sources */, 2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */, 233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */, DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */, @@ -1785,6 +1792,7 @@ ABA8E6402E2F2A2300E27791 /* AppIconButton.swift in Sources */, DD1B8F402B35E2F10022AABC /* GPSStatus.swift in Sources */, 231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */, + BCCA0BAB2F1C5C25007648E5 /* LocalStatsLog.swift in Sources */, 231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */, DD8ED9C52898D51F00B3B0AB /* NetworkConfig.swift in Sources */, DDDE5A1029AFE69700490C6C /* MeshActivityAttributes.swift in Sources */, diff --git a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift index 4fe2ffaf..ebd16c0c 100644 --- a/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift +++ b/Meshtastic/Accessory/Accessory Manager/AccessoryManager+ToRadio.swift @@ -2110,4 +2110,40 @@ extension AccessoryManager { try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription) } + + func sendLocalStatsRequest(destNum: Int64, wantResponse: Bool) async throws { + guard let fromNodeNum = self.activeConnection?.device.num else { + Logger.services.error("Error while sending local stats request. No active device.") + throw AccessoryError.ioFailed("No active device") + } + + var telemetryPacket = Telemetry() + telemetryPacket.localStats = LocalStats() + + var meshPacket = MeshPacket() + meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..(telemetry: S, metricsType: Int) -> String w csvString += ", " csvString += dm.time?.formattedDate(format: dateFormatString) ?? "Unknown Age".localized } + } else if metricsType == 4 { + // Create Local Stats Header + csvString = "Noise Floor, Uptime, Relayed, Canceled, Dupes, Packets Tx, Packets Rx, Bad Rx, Nodes Online, Total Nodes, \("Timestamp".localized)" + for dm in telemetry where dm.metricsType == 4 { + csvString += "\n" + csvString += dm.noiseFloor?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.uptimeSeconds?.formatted(.number.grouping(.never)) ?? "" + csvString += ", " + csvString += dm.numTxRelay.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numTxRelayCanceled.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numRxDupe.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numPacketsTx.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numPacketsRx.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numPacketsRxBad.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numOnlineNodes.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.numTotalNodes.formatted(.number.grouping(.never)) + csvString += ", " + csvString += dm.time?.formattedDate(format: dateFormatString) ?? "Unknown Age".localized + } } return csvString } diff --git a/Meshtastic/Helpers/MeshPackets.swift b/Meshtastic/Helpers/MeshPackets.swift index 255417c4..98b16dd0 100644 --- a/Meshtastic/Helpers/MeshPackets.swift +++ b/Meshtastic/Helpers/MeshPackets.swift @@ -806,8 +806,9 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled) telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes) telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes) + telemetry.noiseFloor = telemetryMessage.localStats.noiseFloor telemetry.metricsType = 4 - Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") + Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Noise Floor: \(telemetryMessage.localStats.noiseFloor, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)") } else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) { Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)") telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage) diff --git a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents index a6e5465f..39a414dd 100644 --- a/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents +++ b/Meshtastic/Meshtastic.xcdatamodeld/MeshtasticDataModelV 55.xcdatamodel/contents @@ -406,6 +406,7 @@ + diff --git a/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift index 79dd0485..5f3878be 100644 --- a/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift +++ b/Meshtastic/Model/CoreData/TelemetryEntity+CoreDataClass.swift @@ -44,6 +44,7 @@ public class TelemetryEntity: NSManagedObject, Identifiable { @ManagedAttribute(attributeName: "windSpeed") public var windSpeed: Float? @ManagedAttribute(attributeName: "irLux") public var irLux: Float? @ManagedAttribute(attributeName: "lux") public var lux: Float? + @ManagedAttribute(attributeName: "noiseFloor") public var noiseFloor: Float? @ManagedAttribute(attributeName: "uvLux") public var uvLux: Float? @ManagedAttribute(attributeName: "whiteLux") public var whiteLux: Float? @ManagedAttribute(attributeName: "radiation") public var radiation: Float? diff --git a/Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift b/Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift new file mode 100644 index 00000000..25866737 --- /dev/null +++ b/Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift @@ -0,0 +1,51 @@ +import SwiftUI +import OSLog + +struct RequestLocalStatsButton: View { + @EnvironmentObject var accessoryManager: AccessoryManager + + var node: NodeInfoEntity + + @State + private var isPresentingLocalStatsSentAlert: Bool = false + + var body: some View { + RateLimitedButton(key: "localstats", rateLimit: 30.0) { + Task { + do { + try await accessoryManager.sendLocalStatsRequest( + destNum: node.user?.num ?? 0, + wantResponse: true + ) + Task { + isPresentingLocalStatsSentAlert = true + } + } catch { + Logger.mesh.warning("Failed to send local stats request: \(error)") + } + } + } label: { completion in + if let completion, completion.percentComplete > 0.0 { + Label { + Text("Local Stats (in \(Int(completion.secondsRemaining))s)") + .foregroundStyle(.secondary) + } icon: { + Image("progress.ring.dashed", variableValue: completion.percentComplete) + .foregroundStyle(.secondary) + }.disabled(true) + } else { + Label { + Text("Request Local Stats") + } icon: { + Image(systemName: "chart.bar") + .symbolRenderingMode(.hierarchical) + } + } + } + .alert("Local Stats Requested", isPresented: $isPresentingLocalStatsSentAlert) { + Button("OK", role: .cancel) { } + } message: { + Text("A local stats request has been sent to \(node.user?.longName ?? "this node"). Responses can some time.") + } + } +} diff --git a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift index dc394f35..9e54747c 100644 --- a/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift +++ b/Meshtastic/Views/Nodes/Helpers/NodeDetail.swift @@ -424,6 +424,17 @@ struct NodeDetail: View { } } .disabled(!node.hasDetectionSensorMetrics) + NavigationLink { + LocalStatsLog(node: node) + } label: { + Label { + Text("Local Stats Log") + } icon: { + Image(systemName: "chart.bar") + .symbolRenderingMode(.multicolor) + } + } + .disabled(node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")).count ?? 0 == 0) if node.hasPax { NavigationLink { PaxCounterLog(node: node) @@ -464,6 +475,7 @@ struct NodeDetail: View { node: node, connectedNode: connectedNode ) + RequestLocalStatsButton(node: node) TraceRouteButton( node: node ) diff --git a/Meshtastic/Views/Nodes/LocalStatsLog.swift b/Meshtastic/Views/Nodes/LocalStatsLog.swift new file mode 100644 index 00000000..fc85690a --- /dev/null +++ b/Meshtastic/Views/Nodes/LocalStatsLog.swift @@ -0,0 +1,266 @@ +// +// LocalStatsLog.swift +// Meshtastic +// +// Copyright(c) Benjamin Faershtein 1/17/26. +// +import SwiftUI +import Charts +import OSLog + +struct LocalStatsLog: View { + + @Environment(\.managedObjectContext) var context + @EnvironmentObject var accessoryManager: AccessoryManager + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @State private var isPresentingClearLogConfirm: Bool = false + @State var isExporting = false + @State var exportString = "" + + @ObservedObject var node: NodeInfoEntity + @State private var sortOrder = [KeyPathComparator(\TelemetryEntity.time, order: .reverse)] + @State private var selection: TelemetryEntity.ID? + @State private var chartSelection: Date? + + private var localStats: [TelemetryEntity] { + let filtered = node.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4")) + return (filtered?.reversed() as? [TelemetryEntity]) ?? [] + } + + private var hasLocalStats: Bool { + !localStats.isEmpty + } + + private var chartData: [TelemetryEntity] { + let oneWeekAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) + return localStats.filter { $0.time != nil && $0.time! >= oneWeekAgo! }.sorted { $0.time! < $1.time! } + } + + private var hasChartData: Bool { + !chartData.isEmpty + } + + private var dateFormatString: String { + let localeDateFormat = DateFormatter.dateFormat(fromTemplate: "yyMdjmma", options: 0, locale: Locale.current) + return (localeDateFormat ?? "M/d/YY j:mma").replacingOccurrences(of: ",", with: "") + } + + var body: some View { + VStack { + if hasLocalStats { + if hasChartData { + chartView + } + tableView + buttonView + } else { + ContentUnavailableView("No Local Stats", systemImage: "waveform") + } + } + .navigationTitle("Local Stats Log") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: + ZStack { + ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?") + }) + .fileExporter( + isPresented: $isExporting, + document: CsvDocument(emptyCsv: exportString), + contentType: .commaSeparatedText, + defaultFilename: String("\(node.user?.longName ?? "Node") \("Local Stats Log".localized)"), + onCompletion: { result in + switch result { + case .success: + self.isExporting = false + Logger.services.info("Local stats log download succeeded.") + case .failure(let error): + Logger.services.error("Local stats log download failed: \(error.localizedDescription, privacy: .public)") + } + } + ) + } + + private var chartView: some View { + GroupBox(label: Label("\(localStats.count) Readings Total", systemImage: "waveform")) { + Chart(chartData) { point in + if let pointTime = point.time, let noiseFloor = point.noiseFloor { + LineMark( + x: .value("Time", pointTime), + y: .value("Noise Floor", noiseFloor) + ) + .foregroundStyle(Color.accentColor) + .interpolationMethod(.linear) + } + RuleMark(y: .value("Icky", -85)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5])) + .foregroundStyle(.red) + } + .chartXAxis(content: { + AxisMarks(position: .top) + }) + .chartXSelection(value: $chartSelection) + .chartYScale(domain: -130 ... -60) + .chartForegroundStyleScale([ + "Noise Floor": Color.accentColor + ]) + .chartLegend(position: .automatic, alignment: .bottom) + } + .frame(minHeight: 240) + } + + @ViewBuilder + private var tableView: some View { + if idiom == .phone { + phoneTableView + } else { + macTableView + } + } + + private var phoneTableView: some View { + Table(localStats, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Local Stats") { ls in + HStack { + Text(ls.time?.formattedDate(format: dateFormatString) ?? "Unknown Age".localized) + .font(.caption) + .fontWeight(.semibold) + Spacer() + } + HStack { + if let noiseFloor = ls.noiseFloor, noiseFloor != 0 { + Text("Noise Floor \(noiseFloor.formatted(.number.precision(.fractionLength(1)))) dBm") + .foregroundColor(noiseFloorColor(noiseFloor)) + } else { + Text("Noise Floor No Reading") + .foregroundColor(.gray) + } + Spacer() + } + HStack { + Text("Relayed: \(ls.numTxRelay)") + Text("Canceled: \(ls.numTxRelayCanceled)") + Text("Dupes: \(ls.numRxDupe)") + Spacer() + } + .font(.caption) + } + .width(ideal: 200, max: .infinity) + } + } + + private var macTableView: some View { + Table(localStats, selection: $selection, sortOrder: $sortOrder) { + TableColumn("Noise Floor") { ls in + if let noiseFloor = ls.noiseFloor, noiseFloor != 0 { + Text("\(noiseFloor.formatted(.number.precision(.fractionLength(1)))) dBm") + .foregroundColor(noiseFloorColor(noiseFloor)) + } else { + Text("No Reading") + .foregroundColor(.gray) + } + } + TableColumn("Uptime") { ls in + if let uptimeSeconds = ls.uptimeSeconds { + let now = Date.now + let later = now + TimeInterval(uptimeSeconds) + let components = (now.. Color { + if value < -100 { + return .green + } else if value < -95 { + return .green + } else if value < -90 { + return .orange + } else { + return .red + } + } +} diff --git a/protobufs b/protobufs index 62ef17b3..1b1dc090 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 62ef17b3d1625fc6d78ed661f614d0baad4be9ef +Subproject commit 1b1dc090ef38f708a276dfb51b17de5ca06b3ade