From 8d74d06672d74eebd14ec00662582863e5ab2256 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:30:01 -0800 Subject: [PATCH 1/2] Add noise floor --- .gitmodules | 3 +- Localizable.xcstrings | 86 +++++- Meshtastic.xcodeproj/project.pbxproj | 8 + .../AccessoryManager+ToRadio.swift | 36 +++ Meshtastic/Export/WriteCsvFile.swift | 27 ++ Meshtastic/Helpers/MeshPackets.swift | 3 +- .../contents | 1 + .../TelemetryEntity+CoreDataClass.swift | 1 + .../Actions/RequestLocalStatsButton.swift | 51 ++++ .../Views/Nodes/Helpers/NodeDetail.swift | 12 + Meshtastic/Views/Nodes/LocalStatsLog.swift | 266 ++++++++++++++++++ protobufs | 2 +- 12 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 Meshtastic/Views/Nodes/Helpers/Actions/RequestLocalStatsButton.swift create mode 100644 Meshtastic/Views/Nodes/LocalStatsLog.swift diff --git a/.gitmodules b/.gitmodules index e6f376a0b3..76fe28bff2 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 33be032d5d..a45ef70f45 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 b87ce5f2c5..fba8c75c0c 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 4fe2ffaf57..ebd16c0c76 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 255417c4f8..98b16dd01d 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 a6e5465f31..39a414dd22 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 79dd04854d..5f3878be24 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 0000000000..25866737e4 --- /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 dc394f35f1..9e54747c59 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 0000000000..fc85690ae4 --- /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 62ef17b3d1..1b1dc090ef 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 62ef17b3d1625fc6d78ed661f614d0baad4be9ef +Subproject commit 1b1dc090ef38f708a276dfb51b17de5ca06b3ade From bbfe0118b6f6bbae6f157b026f527140529d7ef2 Mon Sep 17 00:00:00 2001 From: Benjamin Faershtein <119711889+RCGV1@users.noreply.github.com> Date: Thu, 28 May 2026 17:11:10 -0700 Subject: [PATCH 2/2] Document local stats noise floor --- Meshtastic/Resources/docs/index.json | 24 +++++++++---------- .../Resources/docs/markdown/user/nodes.md | 6 +++++ .../Resources/docs/markdown/user/telemetry.md | 7 ++++++ Meshtastic/Resources/docs/user/nodes.html | 3 +++ Meshtastic/Resources/docs/user/telemetry.html | 7 ++++++ docs/user/nodes.md | 6 +++++ docs/user/telemetry.md | 7 ++++++ 7 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Meshtastic/Resources/docs/index.json b/Meshtastic/Resources/docs/index.json index 6be74858c0..f0aa654ee0 100644 --- a/Meshtastic/Resources/docs/index.json +++ b/Meshtastic/Resources/docs/index.json @@ -358,6 +358,7 @@ "keywords": [ "node", "from", + "packets", "nodes", "key", "device", @@ -384,10 +385,9 @@ "short", "sensor", "row", - "router", - "reduces" + "router" ], - "charCount": 3885 + "charCount": 4406 }, { "id": "settings", @@ -512,11 +512,12 @@ "section": "user", "navOrder": 8, "keywords": [ + "node", "telemetry", "sensor", - "node", "quality", "air", + "packets", "environment", "data", "humidity", @@ -527,23 +528,22 @@ "metrics", "device", "description", + "readings", "power", + "nodes", "interval", "from", "channel", "wind", + "stats", "speed", + "reported", "relative", - "readings", "often", - "meshtastic", - "low", - "level", - "icon", - "hpa", - "detection" + "noise", + "meshtastic" ], - "charCount": 3248 + "charCount": 3882 }, { "id": "translate", diff --git a/Meshtastic/Resources/docs/markdown/user/nodes.md b/Meshtastic/Resources/docs/markdown/user/nodes.md index 8e1499ffa7..ed42b4325c 100644 --- a/Meshtastic/Resources/docs/markdown/user/nodes.md +++ b/Meshtastic/Resources/docs/markdown/user/nodes.md @@ -116,6 +116,12 @@ Tap a node and scroll to the Logs section for detailed metrics: | ![Detection Sensor](../assets/screenshots/logDetectionSensor.png) | Motion or door open/close alerts from the node. | | ![Trace Routes](../assets/screenshots/logTraceRoutes.png) | Recorded trace route paths showing the hops a message took through the mesh. | +## Local Stats and Noise Floor + +Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor. + +Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference. + ## Node Detail View Tap any node to see the full detail view with hardware info, signal metrics, environment sensors, and log navigation: diff --git a/Meshtastic/Resources/docs/markdown/user/telemetry.md b/Meshtastic/Resources/docs/markdown/user/telemetry.md index 7081ae0eba..5b00ea3399 100644 --- a/Meshtastic/Resources/docs/markdown/user/telemetry.md +++ b/Meshtastic/Resources/docs/markdown/user/telemetry.md @@ -13,6 +13,7 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | Type | Data | |------|------| | Device Metrics | Battery level, battery voltage, channel utilisation, airtime fraction | +| Local Stats | Packets received/transmitted, relayed packets, duplicate packets, bad receives, node counts, noise floor | | Environment | Temperature (°C/°F), relative humidity (%), barometric pressure (hPa) | | Air Quality | PM1.0, PM2.5, PM10 particulate counts (µg/m³) | | Power | Voltage and current readings from power monitoring sensors | @@ -27,6 +28,12 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | ![Battery unknown](../assets/screenshots/batteryNil.png) | Unknown | Battery level not reported by this node. | | ![Battery plugged in](../assets/screenshots/batteryPluggedIn.png) | Plugged In | Node is powered via USB/external power. | +### Local Stats + +Local Stats are radio diagnostics reported by the node itself. They help diagnose mesh traffic and receiver conditions with counters for received packets, transmitted packets, relayed packets, duplicate packets, bad receives, canceled packets, online nodes, total nodes, and noise floor. + +Noise floor readings are shown in dBm when available. They can change quickly and should be interpreted with context: antenna direction, nearby interference, and external filters can all affect the displayed value. + ### Air Quality ![IAQ Scale](../assets/screenshots/iaqScale.png) diff --git a/Meshtastic/Resources/docs/user/nodes.html b/Meshtastic/Resources/docs/user/nodes.html index a3fc2106c3..374efc8fac 100644 --- a/Meshtastic/Resources/docs/user/nodes.html +++ b/Meshtastic/Resources/docs/user/nodes.html @@ -213,6 +213,9 @@

Additional Icons

+

Local Stats and Noise Floor

+

Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor.

+

Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference.

Node Detail View

Tap any node to see the full detail view with hardware info, signal metrics, environment sensors, and log navigation:

Node Detail

diff --git a/Meshtastic/Resources/docs/user/telemetry.html b/Meshtastic/Resources/docs/user/telemetry.html index 49d85e34ca..60980c8709 100644 --- a/Meshtastic/Resources/docs/user/telemetry.html +++ b/Meshtastic/Resources/docs/user/telemetry.html @@ -23,6 +23,10 @@

Telemetry Types

Battery level, battery voltage, channel utilisation, airtime fraction +Local Stats +Packets received/transmitted, relayed packets, duplicate packets, bad receives, node counts, noise floor + + Environment Temperature (°C/°F), relative humidity (%), barometric pressure (hPa) @@ -73,6 +77,9 @@

Device Metrics

+

Local Stats

+

Local Stats are radio diagnostics reported by the node itself. They help diagnose mesh traffic and receiver conditions with counters for received packets, transmitted packets, relayed packets, duplicate packets, bad receives, canceled packets, online nodes, total nodes, and noise floor.

+

Noise floor readings are shown in dBm when available. They can change quickly and should be interpreted with context: antenna direction, nearby interference, and external filters can all affect the displayed value.

Air Quality

IAQ Scale

The Indoor Air Quality scale shows category bands from Excellent (green) through Hazardous (maroon). The app supports multiple display modes for air quality readings:

diff --git a/docs/user/nodes.md b/docs/user/nodes.md index 8e1499ffa7..ed42b4325c 100644 --- a/docs/user/nodes.md +++ b/docs/user/nodes.md @@ -116,6 +116,12 @@ Tap a node and scroll to the Logs section for detailed metrics: | ![Detection Sensor](../assets/screenshots/logDetectionSensor.png) | Motion or door open/close alerts from the node. | | ![Trace Routes](../assets/screenshots/logTraceRoutes.png) | Recorded trace route paths showing the hops a message took through the mesh. | +## Local Stats and Noise Floor + +Local Stats show radio diagnostics reported by a node, including packets received, packets transmitted, duplicate packets, relayed packets, bad receives, canceled packets, online node count, total node count, and noise floor. + +Noise floor is displayed in dBm when the node reports it. Treat it as a directional diagnostic instead of an absolute site score: readings can vary quickly, and external filters can lower or skew the displayed value because of insertion loss or in-band interference. + ## Node Detail View Tap any node to see the full detail view with hardware info, signal metrics, environment sensors, and log navigation: diff --git a/docs/user/telemetry.md b/docs/user/telemetry.md index 7081ae0eba..5b00ea3399 100644 --- a/docs/user/telemetry.md +++ b/docs/user/telemetry.md @@ -13,6 +13,7 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | Type | Data | |------|------| | Device Metrics | Battery level, battery voltage, channel utilisation, airtime fraction | +| Local Stats | Packets received/transmitted, relayed packets, duplicate packets, bad receives, node counts, noise floor | | Environment | Temperature (°C/°F), relative humidity (%), barometric pressure (hPa) | | Air Quality | PM1.0, PM2.5, PM10 particulate counts (µg/m³) | | Power | Voltage and current readings from power monitoring sensors | @@ -27,6 +28,12 @@ Meshtastic nodes can report sensor data across the mesh, giving you visibility i | ![Battery unknown](../assets/screenshots/batteryNil.png) | Unknown | Battery level not reported by this node. | | ![Battery plugged in](../assets/screenshots/batteryPluggedIn.png) | Plugged In | Node is powered via USB/external power. | +### Local Stats + +Local Stats are radio diagnostics reported by the node itself. They help diagnose mesh traffic and receiver conditions with counters for received packets, transmitted packets, relayed packets, duplicate packets, bad receives, canceled packets, online nodes, total nodes, and noise floor. + +Noise floor readings are shown in dBm when available. They can change quickly and should be interpreted with context: antenna direction, nearby interference, and external filters can all affect the displayed value. + ### Air Quality ![IAQ Scale](../assets/screenshots/iaqScale.png)