Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@
C54AC9F4DA920A4F718CE21A /* DiscoveryScanEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD14FA280F0A130730DEB93 /* DiscoveryScanEngineTests.swift */; };
DDBEAC0010000000000000D1 /* DiscoveredBeaconEntityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC0010000000000000C1 /* DiscoveredBeaconEntityTests.swift */; };
DDBEAC001000000000000131 /* ModemPresetFrequencySlotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC001000000000000121 /* ModemPresetFrequencySlotTests.swift */; };
DDBEAC001000000000000133 /* LoRaSignalStrengthRatingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC001000000000000123 /* LoRaSignalStrengthRatingTests.swift */; };
DDBEAC001000000000000151 /* BeaconAddVsSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDBEAC001000000000000141 /* BeaconAddVsSwitchTests.swift */; };
C79BFC99EDCC89DD45082753 /* DiscoveryScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5CA8BAC814CF82E127EEABF /* DiscoveryScanView.swift */; };
D02589957BD040969FCB8663 /* PositionEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97241903A924144A3EEB679 /* PositionEntity.swift */; };
Expand Down Expand Up @@ -827,6 +828,7 @@
AAD14FA280F0A130730DEB93 /* DiscoveryScanEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryScanEngineTests.swift; sourceTree = "<group>"; };
DDBEAC0010000000000000C1 /* DiscoveredBeaconEntityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveredBeaconEntityTests.swift; sourceTree = "<group>"; };
DDBEAC001000000000000121 /* ModemPresetFrequencySlotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModemPresetFrequencySlotTests.swift; sourceTree = "<group>"; };
DDBEAC001000000000000123 /* LoRaSignalStrengthRatingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoRaSignalStrengthRatingTests.swift; sourceTree = "<group>"; };
DDBEAC001000000000000141 /* BeaconAddVsSwitchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BeaconAddVsSwitchTests.swift; sourceTree = "<group>"; };
AAFF00000000000000000001 /* OfflineMapRegion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OfflineMapRegion.swift; path = Meshtastic/Helpers/Map/OfflineMapRegion.swift; sourceTree = SOURCE_ROOT; };
AAFF00000000000000000002 /* OfflineMapManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OfflineMapManager.swift; path = Meshtastic/Helpers/Map/OfflineMapManager.swift; sourceTree = SOURCE_ROOT; };
Expand Down Expand Up @@ -1642,6 +1644,7 @@
AAD14FA280F0A130730DEB93 /* DiscoveryScanEngineTests.swift */,
DDBEAC0010000000000000C1 /* DiscoveredBeaconEntityTests.swift */,
DDBEAC001000000000000121 /* ModemPresetFrequencySlotTests.swift */,
DDBEAC001000000000000123 /* LoRaSignalStrengthRatingTests.swift */,
DDBEAC001000000000000141 /* BeaconAddVsSwitchTests.swift */,
DDBEAC001000000000000201 /* MeshBeaconConfigEditorTests.swift */,
18295AFE686247778632C6DE /* NetworkConfigIPConversionTests.swift */,
Expand Down Expand Up @@ -2769,6 +2772,7 @@
C54AC9F4DA920A4F718CE21A /* DiscoveryScanEngineTests.swift in Sources */,
DDBEAC0010000000000000D1 /* DiscoveredBeaconEntityTests.swift in Sources */,
DDBEAC001000000000000131 /* ModemPresetFrequencySlotTests.swift in Sources */,
DDBEAC001000000000000133 /* LoRaSignalStrengthRatingTests.swift in Sources */,
DDBEAC001000000000000151 /* BeaconAddVsSwitchTests.swift in Sources */,
DDBEAC001000000000000211 /* MeshBeaconConfigEditorTests.swift in Sources */,
FDAF8808342245C4817C93EC /* NetworkConfigIPConversionTests.swift in Sources */,
Expand Down
15 changes: 15 additions & 0 deletions Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,21 @@ extension NodeInfoEntity {
return try? ctx.fetch(descriptor).first
}

/// The receiving radio's most recent noise floor (dBm) from Local Stats telemetry
/// (`DeviceMetrics.noise_floor`), or nil when it's absent, zero, or older than the
/// online threshold (2 h). Used to compute a real link margin (`rssi - noiseFloor`)
/// for signal-quality rating instead of guessed fixed RSSI thresholds.
var recentNoiseFloor: Int32? {
guard let stats = latestLocalStats,
let time = stats.time,
let noiseFloor = stats.noiseFloor,
noiseFloor != 0,
let twoHoursAgo = Calendar.current.date(byAdding: .minute, value: -120, to: Date()),
time.compare(twoHoursAgo) == .orderedDescending
else { return nil }
return noiseFloor
}

var hasLocalStats: Bool {
guard let ctx = modelContext else { return false }
let nodeNum = self.num
Expand Down
42 changes: 21 additions & 21 deletions Meshtastic/Resources/docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -434,38 +434,38 @@
"section": "user",
"navOrder": 12,
"keywords": [
"signal",
"quot",
"snr",
"rssi",
"signal",
"noise",
"quot",
"meter",
"rssi",
"radio",
"limit",
"meshtastic",
"than",
"floor",
"static",
"radio",
"background",
"dbm",
"bars",
"bad",
"understand",
"than",
"preset",
"loud",
"lora",
"bars",
"using",
"uses",
"them",
"specific",
"understand",
"still",
"lora",
"local",
"clarity",
"below",
"bad",
"app",
"above",
"two",
"see",
"out",
"message",
"means",
"limit",
"like",
"good",
"friend"
"relative",
"rating"
],
"charCount": 3992
"charCount": 4812
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
},
{
"id": "tak",
Expand Down
18 changes: 11 additions & 7 deletions Meshtastic/Resources/docs/markdown/user/signal-meter.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,20 @@ Depending on which Meshtastic preset you are using (e.g., `LongFast` vs. `ShortF

## 3. How the Signal Meter Calculates Quality

The Meshtastic apps take both RSSI and SNR and run them through a specific formula to assign your signal a quality rating (None, Bad, Fair, or Good). It specifically scales these values based on the physical limits of the radio preset you are using.
The quality rating (None, Bad, Fair, or Good) is based on **SNR relative to your preset's SNR Limit** — how much clarity headroom you have above the point where the radio can no longer decode. This is measured against the physical limit of the radio preset you are using, so the same SNR can mean different things on `LongFast` versus `ShortFast`.

Here is exactly how the app decides how many bars (or what color) to show you:

| Level | Bars | Criteria | Meaning |
| Level | Bars | Criteria (SNR relative to your preset's limit) | Meaning |
|-------|------|----------|---------|
| Good | 3 | RSSI better than `-115 dBm` **AND** SNR above the baseline limit for your preset | Signal is both loud and clear — healthy connection. |
| Fair | 2 | Falls between Good and Bad | Signal getting quieter or noisier, but the radio understands the message fine. |
| Bad | 1 | RSSI drops to `-120 dBm` or worse, **OR** SNR within `5.5 dB` of your preset's absolute breaking point | Barely hanging on — at the edge of range or heavy interference. |
| None | 0 | RSSI worse than `-126 dBm` **AND** SNR has fallen `7.5 dB` below the ideal limit | Transmission completely buried in static. |
| Good | 3 | SNR **above** the limit | Clear headroom — healthy connection. |
| Fair | 2 | SNR at the limit, up to `5.5 dB` below it | Getting quieter or noisier, but the radio still understands the message fine. |
| Bad | 1 | SNR between `5.5 dB` and `7.5 dB` below the limit | Barely hanging on — at the edge of range or heavy interference. |
| None | 0 | SNR more than `7.5 dB` below the limit | Transmission completely buried in static. |

RSSI is still shown next to the meter, but it is no longer used to guess the rating with fixed thresholds — on its own, RSSI can't tell you how much of that power is signal versus noise.

**Using the real noise floor.** When your receiving radio has recently reported its own **noise floor** (part of its Local Stats telemetry), the app does one better: it computes your true link margin as `RSSI − noise floor` and checks that against the same preset limit too, then shows the **more conservative** of the two ratings. This catches the case where the reported SNR looks fine but local interference is quietly eating your margin. When no recent noise floor is available, the app simply uses the SNR-only rating above.

---

Expand All @@ -60,4 +64,4 @@ Because Meshtastic's meter acts as a **"Clarity Meter"**, it behaves differently

> **Tip — Don't panic over low RSSI:** You might see a seemingly terrible RSSI value like `-118 dBm`. On a cell phone, you would have zero bars. But if you have an SNR of `+2 dB`, Meshtastic will still show a strong signal! *The library is quiet, so the whisper is heard perfectly.*

> **Warning — Watch out for local noise:** If you hook up a massive antenna and see a great RSSI (e.g., `-90 dBm`) but your signal meter is only showing **1 Bar (Bad)**, you have a problem. It means you have local interference—perhaps a cheap power supply, a noisy computer, or a nearby radio tower—creating so much static that it is drowning out your mesh.
> **Warning — Watch out for local noise:** If you hook up a massive antenna and see a great RSSI (e.g., `-90 dBm`) but your signal meter is only showing **1 Bar (Bad)**, you have a problem. It means you have local interference—perhaps a cheap power supply, a noisy computer, or a nearby radio tower—creating so much static that it is drowning out your mesh. When your radio knows its own noise floor, the meter accounts for exactly this by measuring your real margin above the noise.
20 changes: 11 additions & 9 deletions Meshtastic/Resources/docs/user/signal-meter.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,48 +28,50 @@ <h2>2. The Magic of LoRa: Hearing &quot;Below the Noise Floor&quot;</h2>
<p>Depending on which Meshtastic preset you are using (e.g., <code>LongFast</code> vs. <code>ShortFast</code>), the radio has a specific <strong>SNR Limit</strong>—the absolute maximum amount of noise it can tolerate before the message is completely lost to the static.</p>
<hr />
<h2>3. How the Signal Meter Calculates Quality</h2>
<p>The Meshtastic apps take both RSSI and SNR and run them through a specific formula to assign your signal a quality rating (None, Bad, Fair, or Good). It specifically scales these values based on the physical limits of the radio preset you are using.</p>
<p>The quality rating (None, Bad, Fair, or Good) is based on <strong>SNR relative to your preset's SNR Limit</strong> — how much clarity headroom you have above the point where the radio can no longer decode. This is measured against the physical limit of the radio preset you are using, so the same SNR can mean different things on <code>LongFast</code> versus <code>ShortFast</code>.</p>
<p>Here is exactly how the app decides how many bars (or what color) to show you:</p>
<table>
<thead>
<tr>
<th>Level</th>
<th>Bars</th>
<th>Criteria</th>
<th>Criteria (SNR relative to your preset's limit)</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td>Good</td>
<td>3</td>
<td>RSSI better than <code>-115 dBm</code> <strong>AND</strong> SNR above the baseline limit for your preset</td>
<td>Signal is both loud and clear — healthy connection.</td>
<td>SNR <strong>above</strong> the limit</td>
<td>Clear headroom — healthy connection.</td>
</tr>
<tr>
<td>Fair</td>
<td>2</td>
<td>Falls between Good and Bad</td>
<td>Signal getting quieter or noisier, but the radio understands the message fine.</td>
<td>SNR at the limit, up to <code>5.5 dB</code> below it</td>
<td>Getting quieter or noisier, but the radio still understands the message fine.</td>
</tr>
<tr>
<td>Bad</td>
<td>1</td>
<td>RSSI drops to <code>-120 dBm</code> or worse, <strong>OR</strong> SNR within <code>5.5 dB</code> of your preset's absolute breaking point</td>
<td>SNR between <code>5.5 dB</code> and <code>7.5 dB</code> below the limit</td>
<td>Barely hanging on — at the edge of range or heavy interference.</td>
</tr>
<tr>
<td>None</td>
<td>0</td>
<td>RSSI worse than <code>-126 dBm</code> <strong>AND</strong> SNR has fallen <code>7.5 dB</code> below the ideal limit</td>
<td>SNR more than <code>7.5 dB</code> below the limit</td>
<td>Transmission completely buried in static.</td>
</tr>
</tbody>
</table>
<p>RSSI is still shown next to the meter, but it is no longer used to guess the rating with fixed thresholds — on its own, RSSI can't tell you how much of that power is signal versus noise.</p>
<p><strong>Using the real noise floor.</strong> When your receiving radio has recently reported its own <strong>noise floor</strong> (part of its Local Stats telemetry), the app does one better: it computes your true link margin as <code>RSSI − noise floor</code> and checks that against the same preset limit too, then shows the <strong>more conservative</strong> of the two ratings. This catches the case where the reported SNR looks fine but local interference is quietly eating your margin. When no recent noise floor is available, the app simply uses the SNR-only rating above.</p>
<hr />
<h2>4. What This Means for You</h2>
<p>Because Meshtastic's meter acts as a <strong>&quot;Clarity Meter&quot;</strong>, it behaves differently than what most people expect:</p>
<div class="tips-callout"><strong>Tip — Don't panic over low RSSI:</strong> You might see a seemingly terrible RSSI value like <code>-118 dBm</code>. On a cell phone, you would have zero bars. But if you have an SNR of <code>+2 dB</code>, Meshtastic will still show a strong signal! <em>The library is quiet, so the whisper is heard perfectly.</em></div>
<div class="warning-callout"><strong>Warning — Watch out for local noise:</strong> If you hook up a massive antenna and see a great RSSI (e.g., <code>-90 dBm</code>) but your signal meter is only showing <strong>1 Bar (Bad)</strong>, you have a problem. It means you have local interference—perhaps a cheap power supply, a noisy computer, or a nearby radio tower—creating so much static that it is drowning out your mesh.</div>
<div class="warning-callout"><strong>Warning — Watch out for local noise:</strong> If you hook up a massive antenna and see a great RSSI (e.g., <code>-90 dBm</code>) but your signal meter is only showing <strong>1 Bar (Bad)</strong>, you have a problem. It means you have local interference—perhaps a cheap power supply, a noisy computer, or a nearby radio tower—creating so much static that it is drowning out your mesh. When your radio knows its own noise floor, the meter accounts for exactly this by measuring your real margin above the noise.</div>
</body>
</html>
7 changes: 5 additions & 2 deletions Meshtastic/Views/Helpers/LoRaSignalStrength.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ struct LoRaSignalStrengthMeter: View {
var rssi: Int32
var preset: ModemPresets
var compact: Bool
/// The receiving radio's noise floor (dBm), when known, so the rating can use the
/// real link margin instead of SNR alone. See `getLoRaSignalStrength`.
var noiseFloor: Int32?
var body: some View {

let signalStrength = getLoRaSignalStrength(snr: snr, rssi: rssi, preset: preset)
let signalStrength = getLoRaSignalStrength(snr: snr, rssi: rssi, preset: preset, noiseFloor: noiseFloor)
let gradient = Gradient(colors: [.red, .orange, .yellow, .green])
if !compact {
VStack {
LoRaSignalStrengthIndicator(signalStrength: signalStrength)
Text("Signal \(signalStrength.description)").font(.footnote)
Text("SNR \(String(format: "%.2f", snr))dB")
.foregroundColor(getSnrColor(snr: snr, preset: ModemPresets.longFast))
.foregroundColor(signalStrength.color)
.font(.caption2)
Text("RSSI \(rssi)dB")
.foregroundColor(getRssiColor(rssi: rssi))
Expand Down
76 changes: 46 additions & 30 deletions Meshtastic/Views/Helpers/LoRaSignalStrengthIndicator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ struct LoRaSignalStrengthIndicator_Previews: PreviewProvider {
}
}

enum LoRaSignalStrength: Int {
enum LoRaSignalStrength: Int, Comparable {
case none = 0
case bad = 1
case fair = 2
Expand All @@ -56,6 +56,14 @@ enum LoRaSignalStrength: Int {
return "Good".localized
}
}
/// The color for this tier. Exposed so the bar indicator and any accompanying
/// SNR text can be driven from the *same* rating and never disagree.
var color: Color {
getColor(signalStrength: self)
}
static func < (lhs: LoRaSignalStrength, rhs: LoRaSignalStrength) -> Bool {
lhs.rawValue < rhs.rawValue
}
}

private func getColor(signalStrength: LoRaSignalStrength) -> Color {
Expand All @@ -71,28 +79,43 @@ private func getColor(signalStrength: LoRaSignalStrength) -> Color {
}
}

func getLoRaSignalStrength(snr: Float, rssi: Int32, preset: ModemPresets) -> LoRaSignalStrength {
// rssi is 0 when not available
if rssi == 0 {
if snr > (preset.snrLimit()) {
return .good
}
if snr < (preset.snrLimit() - 7.5) {
return .none
}
if snr <= (preset.snrLimit() - 5.5) {
return .bad
}
/// Single source of truth for the 4-tier signal rating: how far a signal-to-noise
/// margin sits above (or below) the preset's demodulation floor. `snrMargin` is
/// `snr - preset.snrLimit()` in dB. Both the bar indicator and the SNR text color
/// derive from this, so they can never disagree on the same reading.
func signalQuality(snrMargin: Float) -> LoRaSignalStrength {
if snrMargin > 0 {
return .good
} else if snrMargin > -5.5 {
return .fair
} else if snrMargin >= -7.5 {
return .bad
} else {
return .none
}
}

if rssi > -115 && snr > (preset.snrLimit()) {
return .good
} else if rssi < -126 && snr < (preset.snrLimit() - 7.5) {
return .none
} else if rssi <= -120 || snr <= (preset.snrLimit() - 5.5) {
return .bad
} else { return .fair }
/// Rates link quality for a directly-received packet.
///
/// Primary signal is the reported packet SNR relative to the preset's demodulation
/// floor. When the *receiving* node has a recent noise-floor reading (from Local
/// Stats telemetry, `DeviceMetrics.noise_floor`), we also derive a second SNR
/// estimate from the real link margin (`rssi - noiseFloor`) and take the more
/// conservative of the two tiers — this is more accurate than trusting a single
/// estimate. When no noise floor is available we fall back to SNR-only (matching
/// Meshtastic-Android's `determineSignalQuality`), rather than the old guessed
/// fixed RSSI thresholds (-115/-120/-126), which could not know the noise floor.
func getLoRaSignalStrength(snr: Float, rssi: Int32, preset: ModemPresets, noiseFloor: Int32? = nil) -> LoRaSignalStrength {
let limit = preset.snrLimit()
let snrTier = signalQuality(snrMargin: snr - limit)

// Use the actual link margin only when we have both a real RSSI reading and a
// real noise floor for the receiving radio; otherwise stay SNR-only.
if let noiseFloor, noiseFloor != 0, rssi != 0 {
let rssiTier = signalQuality(snrMargin: Float(rssi - noiseFloor) - limit)
return min(snrTier, rssiTier)
}
return snrTier
}

func getRssiColor(rssi: Int32) -> Color {
Expand All @@ -111,14 +134,7 @@ func getRssiColor(rssi: Int32) -> Color {
}

func getSnrColor(snr: Float, preset: ModemPresets) -> Color {
if snr > preset.snrLimit() {
/// Good
return .green
} else if snr < preset.snrLimit() && snr > (preset.snrLimit() - 5.5) {
/// Fair
return .yellow
} else if snr >= (preset.snrLimit() - 7.5) {
/// Bad
return .orange
} else { return .red }
// SNR-only rating, shared with the bar indicator via signalQuality() so the
// two surfaces can never disagree.
signalQuality(snrMargin: snr - preset.snrLimit()).color
}
Loading
Loading