feat(ui): use modem-preset-relative SNR thresholds for signal quality (#5903)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-06-22 11:57:07 -05:00
committed by GitHub
parent f73deee226
commit 27e936f104
13 changed files with 295 additions and 102 deletions

View File

@@ -40,6 +40,18 @@ private val ModemPreset.bandwidth: Float
return 0f
}
/**
* The SNR (in dB) at or above which a packet sent with this modem preset can still be demodulated — i.e. the
* spreading-factor-determined demodulation floor. Signal quality should be judged relative to this limit, since the
* same SNR means very different things per preset: -15 dB is excellent on LongSlow (SF12) yet unusable on ShortFast
* (SF7).
*
* Values follow the Semtech SF -> SNR floor (SF7 -7.5 dB, stepping -2.5 dB per spreading factor up to SF12 -20 dB).
* Unknown/unset presets fall back to [ChannelOption.DEFAULT] (LongFast).
*/
val ModemPreset?.snrLimit: Float
get() = (ChannelOption.from(this) ?: ChannelOption.DEFAULT).snrLimit
private fun LoRaConfig.bandwidth(regionInfo: RegionInfo?) = if (use_preset) {
modem_preset.bandwidth * if (regionInfo?.wideLora == true) 3.25f else 1f
} else {
@@ -295,22 +307,26 @@ enum class RegionInfo(
}
}
enum class ChannelOption(val modemPreset: ModemPreset, val bandwidth: Float) {
// Grouped by range and speed for better readability
VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, 0.0625f),
LONG_TURBO(ModemPreset.LONG_TURBO, 0.500f),
LONG_FAST(ModemPreset.LONG_FAST, 0.250f),
LONG_MODERATE(ModemPreset.LONG_MODERATE, 0.125f),
LONG_SLOW(ModemPreset.LONG_SLOW, 0.125f),
MEDIUM_FAST(ModemPreset.MEDIUM_FAST, 0.250f),
MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, 0.250f),
SHORT_FAST(ModemPreset.SHORT_FAST, 0.250f),
SHORT_SLOW(ModemPreset.SHORT_SLOW, 0.250f),
SHORT_TURBO(ModemPreset.SHORT_TURBO, 0.500f),
LITE_FAST(ModemPreset.LITE_FAST, 0.125f),
LITE_SLOW(ModemPreset.LITE_SLOW, 0.125f),
NARROW_FAST(ModemPreset.NARROW_FAST, 0.0625f),
NARROW_SLOW(ModemPreset.NARROW_SLOW, 0.0625f),
enum class ChannelOption(val modemPreset: ModemPreset, val bandwidth: Float, val snrLimit: Float) {
// Grouped by range and speed for better readability.
// snrLimit = demodulation floor for the preset's spreading factor (see [ModemPreset.snrLimit]).
VERY_LONG_SLOW(ModemPreset.VERY_LONG_SLOW, 0.0625f, snrLimit = -20f), // SF12
LONG_TURBO(ModemPreset.LONG_TURBO, 0.500f, snrLimit = -12.5f), // SF9
LONG_FAST(ModemPreset.LONG_FAST, 0.250f, snrLimit = -17.5f), // SF11
LONG_MODERATE(ModemPreset.LONG_MODERATE, 0.125f, snrLimit = -17.5f), // SF11
// SF12: physically -20 dB. NB: Meshtastic-Apple's snrLimit() returns -7.5 here, which is the SF7 value
// and an apparent bug — see meshtastic/Meshtastic-Android#5446.
LONG_SLOW(ModemPreset.LONG_SLOW, 0.125f, snrLimit = -20f), // SF12
MEDIUM_FAST(ModemPreset.MEDIUM_FAST, 0.250f, snrLimit = -12.5f), // SF9
MEDIUM_SLOW(ModemPreset.MEDIUM_SLOW, 0.250f, snrLimit = -15f), // SF10
SHORT_FAST(ModemPreset.SHORT_FAST, 0.250f, snrLimit = -7.5f), // SF7
SHORT_SLOW(ModemPreset.SHORT_SLOW, 0.250f, snrLimit = -10f), // SF8
SHORT_TURBO(ModemPreset.SHORT_TURBO, 0.500f, snrLimit = -7.5f), // SF7
LITE_FAST(ModemPreset.LITE_FAST, 0.125f, snrLimit = -12.5f),
LITE_SLOW(ModemPreset.LITE_SLOW, 0.125f, snrLimit = -15f),
NARROW_FAST(ModemPreset.NARROW_FAST, 0.0625f, snrLimit = -10f),
NARROW_SLOW(ModemPreset.NARROW_SLOW, 0.0625f, snrLimit = -12.5f),
;
companion object {

View File

@@ -33,6 +33,7 @@ import org.meshtastic.core.resources.a11y_node_signal
import org.meshtastic.core.resources.now
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
private const val MILLIS_PER_SECOND = 1000L
private const val MAX_BATTERY_PERCENT = 100
@@ -86,6 +87,7 @@ internal fun buildNodeDescription(
viaMqtt: Boolean,
strings: NodeDescriptionStrings,
lastHeardIsRelative: Boolean = true,
modemPreset: ModemPreset? = null,
): String = buildString {
append(name)
append(", ")
@@ -121,7 +123,7 @@ internal fun buildNodeDescription(
append(strings.distanceAway.replace("%s", it))
}
if (hopsAway == 0 && !viaMqtt && snr < SNR_UNSET_THRESHOLD && rssi < 0) {
val quality = determineSignalQuality(snr, rssi)
val quality = determineSignalQuality(snr, modemPreset)
append(", ")
append(strings.signal.replace("%s", quality.name.lowercase()))
}

View File

@@ -42,6 +42,7 @@ import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.compose.resources.vectorResource
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.snrLimit
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
@@ -59,13 +60,22 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
// Fixed-threshold SNR colors retained for contexts without an active preset (e.g. traceroute hop coloring in
// AnnotatedStrings). Per-node signal quality uses preset-relative thresholds instead — see [determineSignalQuality].
const val SNR_GOOD_THRESHOLD = -7f
const val SNR_FAIR_THRESHOLD = -15f
const val RSSI_GOOD_THRESHOLD = -115
const val RSSI_FAIR_THRESHOLD = -126
// SNR offsets (dB) below a preset's demodulation floor that delimit the quality bands, matching Meshtastic-Apple's
// getSnrColor(): within 5.5 dB below the limit is FAIR, within 7.5 dB is BAD, further down is NONE.
private const val SNR_FAIR_OFFSET = 5.5f
private const val SNR_BAD_OFFSET = 7.5f
@Stable
enum class Quality(
@Stable val nameRes: StringResource,
@@ -84,14 +94,19 @@ enum class Quality(
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun NodeSignalQuality(snr: Float, rssi: Int, modifier: Modifier = Modifier) {
val quality = determineSignalQuality(snr, rssi)
fun NodeSignalQuality(
snr: Float,
rssi: Int,
modifier: Modifier = Modifier,
modemPreset: ModemPreset? = LocalModemPreset.current,
) {
val quality = determineSignalQuality(snr, modemPreset)
FlowRow(
modifier = modifier,
itemVerticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Snr(snr)
Snr(snr, modemPreset = modemPreset)
Rssi(rssi)
Text(
text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
@@ -111,21 +126,26 @@ private const val SIZE_ICON_DP = 16
/** Displays the `snr` and `rssi` with color depending on the values respectively. */
@Composable
fun SnrAndRssi(snr: Float, rssi: Int) {
fun SnrAndRssi(snr: Float, rssi: Int, modemPreset: ModemPreset? = LocalModemPreset.current) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Snr(snr)
Snr(snr, modemPreset = modemPreset)
Rssi(rssi)
}
}
/** Displays a human readable description and icon representing the signal quality. */
@Composable
fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialTheme.colorScheme.onSurface) {
val quality = determineSignalQuality(snr, rssi)
fun LoraSignalIndicator(
snr: Float,
modifier: Modifier = Modifier,
modemPreset: ModemPreset? = LocalModemPreset.current,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val quality = determineSignalQuality(snr, modemPreset)
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize().padding(8.dp),
modifier = modifier.fillMaxSize().padding(8.dp),
) {
Icon(
modifier = Modifier.size(SIZE_ICON_DP.dp),
@@ -142,15 +162,8 @@ fun LoraSignalIndicator(snr: Float, rssi: Int, contentColor: Color = MaterialThe
}
@Composable
fun Snr(snr: Float, modifier: Modifier = Modifier) {
val color: Color =
if (snr > SNR_GOOD_THRESHOLD) {
Quality.GOOD.color.invoke()
} else if (snr > SNR_FAIR_THRESHOLD) {
Quality.FAIR.color.invoke()
} else {
Quality.BAD.color.invoke()
}
fun Snr(snr: Float, modifier: Modifier = Modifier, modemPreset: ModemPreset? = LocalModemPreset.current) {
val color: Color = determineSignalQuality(snr, modemPreset).color.invoke()
Text(
modifier = modifier,
@@ -178,10 +191,22 @@ fun Rssi(rssi: Int, modifier: Modifier = Modifier) {
)
}
fun determineSignalQuality(snr: Float, rssi: Int): Quality = when {
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.GOOD
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> Quality.FAIR
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> Quality.FAIR
snr <= SNR_FAIR_THRESHOLD && rssi <= RSSI_FAIR_THRESHOLD -> Quality.NONE
else -> Quality.BAD
/**
* Rates link quality from SNR relative to the active modem preset's demodulation floor ([ModemPreset.snrLimit]). A
* given SNR means different things per preset — e.g. -15 dB is excellent on LongSlow (SF12) but unusable on ShortFast
* (SF7) — so a fixed threshold mis-rates most presets.
*
* RSSI is intentionally not considered: without the noise floor it cannot indicate whether a signal is demodulable, so
* SNR-versus-preset-limit is the meaningful measure (it is still shown to the user via [Rssi]). See #5446.
*
* A null/unknown [modemPreset] falls back to the LongFast default limit.
*/
fun determineSignalQuality(snr: Float, modemPreset: ModemPreset?): Quality {
val limit = modemPreset.snrLimit
return when {
snr > limit -> Quality.GOOD
snr > limit - SNR_FAIR_OFFSET -> Quality.FAIR
snr >= limit - SNR_BAD_OFFSET -> Quality.BAD
else -> Quality.NONE
}
}

View File

@@ -17,11 +17,20 @@
package org.meshtastic.core.ui.component
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.compose.koinInject
import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.NodeDetailRoute
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.core.ui.viewmodel.UIViewModel
/**
@@ -53,5 +62,16 @@ fun MeshtasticAppShell(
},
)
MeshtasticSnackbarProvider(snackbarManager = uiViewModel.snackbarManager, hostModifier = hostModifier) { content() }
// Connected device's modem preset, provided once here so signal-quality rating is preset-relative across all
// screens without per-screen plumbing. Distinct so the value only changes when the preset itself does.
val radioConfigRepository = koinInject<RadioConfigRepository>()
val modemPreset by
remember(radioConfigRepository) {
radioConfigRepository.localConfigFlow.map { it.lora?.modem_preset }.distinctUntilChanged()
}
.collectAsStateWithLifecycle(initialValue = null)
MeshtasticSnackbarProvider(snackbarManager = uiViewModel.snackbarManager, hostModifier = hostModifier) {
CompositionLocalProvider(LocalModemPreset provides modemPreset) { content() }
}
}

View File

@@ -75,6 +75,7 @@ import org.meshtastic.core.ui.icon.MapCompass
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Notes
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.proto.Config
private const val ACTIVE_BORDER_ALPHA = 0.65f
@@ -140,8 +141,9 @@ fun NodeItem(
}
val a11yStrings = rememberNodeDescriptionStrings()
val modemPreset = LocalModemPreset.current
val nodeDescription =
remember(thatNode, a11yStrings) {
remember(thatNode, a11yStrings, modemPreset) {
buildNodeDescription(
name = originalLongName,
isOnline = thatNode.isOnline,
@@ -155,6 +157,7 @@ fun NodeItem(
rssi = thatNode.rssi,
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
modemPreset = modemPreset,
)
}
@@ -317,7 +320,7 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
if (thatNode.snr < 100f) add { Snr(thatNode.snr) }
if (thatNode.rssi < 0) add { Rssi(thatNode.rssi) }
if (thatNode.snr < 100f && thatNode.rssi < 0) {
val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
add {
IconInfo(
icon = vectorResource(quality.icon),

View File

@@ -89,6 +89,7 @@ import org.meshtastic.core.ui.icon.Unmessageable
import org.meshtastic.core.ui.icon.role
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.proto.Config
private const val ACTIVE_BORDER_ALPHA = 0.65f
@@ -152,8 +153,9 @@ fun NodeItemCompact(
val style = if (thatNode.isUnknownUser) FontStyle.Italic else FontStyle.Normal
val a11yStrings = rememberNodeDescriptionStrings()
val modemPreset = LocalModemPreset.current
val nodeDescription =
remember(thatNode, lastHeardIsRelative, a11yStrings) {
remember(thatNode, lastHeardIsRelative, a11yStrings, modemPreset) {
buildNodeDescription(
name = longName,
isOnline = thatNode.isOnline,
@@ -168,6 +170,7 @@ fun NodeItemCompact(
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
lastHeardIsRelative = lastHeardIsRelative,
modemPreset = modemPreset,
)
}
@@ -371,7 +374,7 @@ private fun CompactHealthRow(
// Signal quality
val hasDirectSignal = thatNode.hopsAway == 0 && thatNode.snr < 100f && !thatNode.viaMqtt && thatNode.rssi < 0
if (showSignal && hasDirectSignal) {
val quality = determineSignalQuality(thatNode.snr, thatNode.rssi)
val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
add(
@Composable {
IconInfo(

View File

@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalModemPreset
const val MAX_VALID_SNR = 100F
const val MAX_VALID_RSSI = 0
@@ -51,7 +52,7 @@ fun SignalInfo(
@Suppress("UNUSED_PARAMETER") contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
val quality = determineSignalQuality(node.snr, node.rssi)
val quality = determineSignalQuality(node.snr, LocalModemPreset.current)
val signalColor = quality.color.invoke()
Row(
modifier = modifier,

View File

@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.compositionLocalOf
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
/**
* The connected device's active LoRa modem preset, provided once at the app root (see MeshtasticAppShell) so signal
* quality can be rated relative to the preset's demodulation floor without threading it through every node/message
* composable. Null — the default before a device connects, and in previews/tests — falls back to the LongFast limit in
* `ModemPreset?.snrLimit`.
*/
@Suppress("CompositionLocalAllowlist")
val LocalModemPreset = compositionLocalOf<ModemPreset?> { null }

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import org.meshtastic.core.model.snrLimit
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Tests for preset-relative signal-quality rating (issue #5446). Quality is judged from SNR relative to the modem
* preset's demodulation floor; RSSI is intentionally not part of the rating.
*/
class LoraSignalIndicatorTest {
@Test
fun `snrLimit follows spreading-factor demod floor per preset`() {
assertEquals(-7.5f, ModemPreset.SHORT_FAST.snrLimit) // SF7
assertEquals(-7.5f, ModemPreset.SHORT_TURBO.snrLimit) // SF7
assertEquals(-10f, ModemPreset.SHORT_SLOW.snrLimit) // SF8
assertEquals(-12.5f, ModemPreset.MEDIUM_FAST.snrLimit) // SF9
assertEquals(-15f, ModemPreset.MEDIUM_SLOW.snrLimit) // SF10
assertEquals(-17.5f, ModemPreset.LONG_FAST.snrLimit) // SF11
assertEquals(-17.5f, ModemPreset.LONG_MODERATE.snrLimit) // SF11
}
@Test
fun `LONG_SLOW uses physically-correct SF12 floor`() {
// Meshtastic-Apple's snrLimit() returns -7.5 here (the SF7 value, an apparent bug). The correct SF12
// demodulation floor is -20 dB — see #5446.
assertEquals(-20f, ModemPreset.LONG_SLOW.snrLimit)
assertEquals(-20f, ModemPreset.VERY_LONG_SLOW.snrLimit)
}
@Test
fun `null preset falls back to the LongFast default limit`() {
val noPreset: ModemPreset? = null
assertEquals(-17.5f, noPreset.snrLimit)
}
@Test
fun `the issue's example minus 10 dB SNR rates GOOD on LongFast`() {
// The bug this issue fixes: a fixed threshold rated -10 dB as BAD, but on LongFast (floor -17.5) it is
// 7.5 dB above the demod floor — an excellent signal.
assertEquals(Quality.GOOD, determineSignalQuality(snr = -10f, modemPreset = ModemPreset.LONG_FAST))
}
@Test
fun `the same SNR is rated relative to the preset`() {
// -15 dB is comfortably above LongSlow's -20 floor but at/below ShortFast's -7.5 floor.
assertEquals(Quality.GOOD, determineSignalQuality(snr = -15f, modemPreset = ModemPreset.LONG_SLOW))
assertEquals(Quality.BAD, determineSignalQuality(snr = -15f, modemPreset = ModemPreset.SHORT_FAST))
}
@Test
fun `quality bands around the LongFast floor`() {
// limit = -17.5; FAIR within 5.5 dB below, BAD within 7.5 dB below, NONE beyond.
val preset = ModemPreset.LONG_FAST
assertEquals(Quality.GOOD, determineSignalQuality(snr = -17f, modemPreset = preset)) // > limit
assertEquals(Quality.FAIR, determineSignalQuality(snr = -17.5f, modemPreset = preset)) // at limit
assertEquals(Quality.FAIR, determineSignalQuality(snr = -22f, modemPreset = preset)) // > limit-5.5 (-23)
assertEquals(Quality.BAD, determineSignalQuality(snr = -23f, modemPreset = preset)) // >= limit-7.5 (-25)
assertEquals(Quality.NONE, determineSignalQuality(snr = -30f, modemPreset = preset)) // < limit-7.5
}
@Test
fun `RSSI does not influence the rating`() {
// Identical SNR + preset always yields the same verdict regardless of any RSSI (RSSI is display-only now).
val good = determineSignalQuality(snr = -5f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(Quality.GOOD, good)
}
}

View File

@@ -179,8 +179,12 @@ class CarStateCoordinator(
private fun collectNodeData() {
nodeJob =
scope.launch {
combine(nodeRepository.nodeDBbyNum, nodeRepository.onlineNodeCount) { nodeMap, onlineCount ->
val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values)
combine(
nodeRepository.nodeDBbyNum,
nodeRepository.onlineNodeCount,
radioConfigRepository.localConfigFlow,
) { nodeMap, onlineCount, localConfig ->
val nodes = CarScreenDataBuilder.sortNodes(nodeMap.values, localConfig.lora?.modem_preset)
val totalCount = nodeMap.size
val meshName = nodeRepository.myNodeInfo.value?.firmwareVersion

View File

@@ -18,11 +18,13 @@ package org.meshtastic.feature.car.util
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.model.snrLimit
import org.meshtastic.feature.car.model.CarLocalStats
import org.meshtastic.feature.car.model.ConversationUi
import org.meshtastic.feature.car.model.NodeUi
import org.meshtastic.feature.car.model.SignalQuality
import org.meshtastic.feature.car.service.MessageSnapshot
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.LocalStats
/**
@@ -39,19 +41,19 @@ internal object CarScreenDataBuilder {
private const val DAY_SECONDS = 86400
private const val BATTERY_MAX_PERCENT = 100
// Thresholds aligned with core/ui LoraSignalIndicator.kt
private const val SNR_GOOD_THRESHOLD = -7f
private const val SNR_FAIR_THRESHOLD = -15f
private const val RSSI_GOOD_THRESHOLD = -115
private const val RSSI_FAIR_THRESHOLD = -126
// Preset-relative SNR band offsets (dB) around the modem preset's demodulation floor; aligned with core/ui
// LoraSignalIndicator. EXCELLENT sits a margin above the floor; FAIR/BAD step below it.
private const val SNR_EXCELLENT_MARGIN = 5.5f
private const val SNR_FAIR_OFFSET = 5.5f
private const val SNR_BAD_OFFSET = 7.5f
/** Converts a [Node] to a [NodeUi] for car display. */
fun buildNodeUi(node: Node): NodeUi = NodeUi(
fun buildNodeUi(node: Node, modemPreset: ModemPreset? = null): NodeUi = NodeUi(
nodeNum = node.num,
userId = node.user.id,
longName = node.user.long_name.ifEmpty { "Unknown" },
shortName = node.user.short_name.ifEmpty { "?" },
signalQuality = determineSignalQuality(node.snr, node.rssi),
signalQuality = determineSignalQuality(node.snr, modemPreset),
batteryPercent = node.batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
isOnline = node.isOnline,
lastHeard = node.lastHeard.toLong() * SECONDS_TO_MILLIS,
@@ -59,22 +61,28 @@ internal object CarScreenDataBuilder {
)
/** Sorts nodes for car display: online nodes first, then by lastHeard descending. */
fun sortNodes(nodes: Collection<Node>): List<NodeUi> = nodes
.map(::buildNodeUi)
fun sortNodes(nodes: Collection<Node>, modemPreset: ModemPreset? = null): List<NodeUi> = nodes
.map { buildNodeUi(it, modemPreset) }
.sortedWith(compareByDescending<NodeUi> { it.isOnline }.thenByDescending { it.lastHeard })
/** Builds ordered conversation list: sorted by most recent message time descending. */
fun sortConversations(conversations: List<ConversationUi>): List<ConversationUi> =
conversations.sortedByDescending { it.lastMessageTime }
/** Determines signal quality from SNR and RSSI values. */
fun determineSignalQuality(snr: Float, rssi: Int): SignalQuality = when {
snr == Float.MAX_VALUE || rssi == Int.MAX_VALUE -> SignalQuality.NONE
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.EXCELLENT
snr > SNR_GOOD_THRESHOLD && rssi > RSSI_FAIR_THRESHOLD -> SignalQuality.GOOD
snr > SNR_FAIR_THRESHOLD && rssi > RSSI_GOOD_THRESHOLD -> SignalQuality.GOOD
snr > SNR_FAIR_THRESHOLD -> SignalQuality.FAIR
else -> SignalQuality.BAD
/**
* Determines signal quality from SNR relative to the modem preset's demodulation floor ([ModemPreset.snrLimit]).
* RSSI is not used (matching core/ui); a null/unknown preset falls back to the LongFast default limit.
*/
fun determineSignalQuality(snr: Float, modemPreset: ModemPreset? = null): SignalQuality {
if (snr == Float.MAX_VALUE) return SignalQuality.NONE
val limit = modemPreset.snrLimit
return when {
snr > limit + SNR_EXCELLENT_MARGIN -> SignalQuality.EXCELLENT
snr > limit -> SignalQuality.GOOD
snr > limit - SNR_FAIR_OFFSET -> SignalQuality.FAIR
snr >= limit - SNR_BAD_OFFSET -> SignalQuality.BAD
else -> SignalQuality.NONE
}
}
/**

View File

@@ -40,6 +40,7 @@ import org.meshtastic.core.model.util.onlineTimeThreshold
import org.meshtastic.feature.car.model.ConversationUi
import org.meshtastic.feature.car.model.SignalQuality
import org.meshtastic.feature.car.service.MessageSnapshot
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.Position
@@ -52,76 +53,71 @@ import kotlin.test.assertTrue
class CarScreenDataBuilderTest {
// determineSignalQuality()
// determineSignalQuality() — preset-relative SNR, RSSI not used (issue #5446)
@Test
fun `determineSignalQuality returns none when snr is max value`() {
val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, -100)
val quality = CarScreenDataBuilder.determineSignalQuality(Float.MAX_VALUE, ModemPreset.LONG_FAST)
assertEquals(SignalQuality.NONE, quality)
}
@Test
fun `determineSignalQuality returns none when rssi is max value`() {
val quality = CarScreenDataBuilder.determineSignalQuality(-5f, Int.MAX_VALUE)
assertEquals(SignalQuality.NONE, quality)
}
@Test
fun `determineSignalQuality returns excellent for strong snr and strong rssi`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -110)
fun `determineSignalQuality returns excellent well above the preset floor`() {
// LongFast floor -17.5; -10 is 7.5 dB above it (> floor + 5.5 margin).
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.EXCELLENT, quality)
}
@Test
fun `determineSignalQuality returns good for strong snr and fair rssi`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -120)
fun `determineSignalQuality returns good just above the preset floor`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.GOOD, quality)
}
@Test
fun `determineSignalQuality returns good for fair snr and strong rssi`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -110)
assertEquals(SignalQuality.GOOD, quality)
}
@Test
fun `determineSignalQuality returns fair for fair snr and weak rssi`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, rssi = -130)
fun `determineSignalQuality returns fair just below the preset floor`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -20f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.FAIR, quality)
}
@Test
fun `determineSignalQuality returns bad for weak snr`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -110)
fun `determineSignalQuality returns bad further below the floor`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -24f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.BAD, quality)
}
@Test
fun `determineSignalQuality treats snr good threshold as not excellent`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -7f, rssi = -110)
fun `determineSignalQuality returns none far below the floor`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -30f, modemPreset = ModemPreset.LONG_FAST)
assertEquals(SignalQuality.NONE, quality)
}
@Test
fun `determineSignalQuality rates the same snr relative to the preset`() {
// -15 dB clears LongSlow's -20 floor but sits at/below ShortFast's -7.5 floor.
assertEquals(SignalQuality.GOOD, CarScreenDataBuilder.determineSignalQuality(-15f, ModemPreset.LONG_SLOW))
assertEquals(SignalQuality.BAD, CarScreenDataBuilder.determineSignalQuality(-15f, ModemPreset.SHORT_FAST))
}
@Test
fun `determineSignalQuality uses the corrected LongSlow SF12 floor`() {
// -19 dB clears the physically-correct -20 floor (GOOD); it would be NONE under Apple's bugged -7.5.
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -19f, modemPreset = ModemPreset.LONG_SLOW)
assertEquals(SignalQuality.GOOD, quality)
}
@Test
fun `determineSignalQuality treats rssi fair threshold as not good for strong snr`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -6f, rssi = -126)
fun `determineSignalQuality falls back to LongFast for a null preset`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -10f, modemPreset = null)
assertEquals(SignalQuality.FAIR, quality)
}
@Test
fun `determineSignalQuality treats snr fair threshold as bad`() {
val quality = CarScreenDataBuilder.determineSignalQuality(snr = -15f, rssi = -130)
assertEquals(SignalQuality.BAD, quality)
assertEquals(SignalQuality.EXCELLENT, quality)
}
// buildNodeUi()

View File

@@ -564,7 +564,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rx_snr, meshPacket.rx_rssi)
LoraSignalIndicator(snr = meshPacket.rx_snr)
}
}
}