From 27e936f1045e1fdcc9e256e4ed401980e7b8f3dc Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:57:07 -0500 Subject: [PATCH] feat(ui): use modem-preset-relative SNR thresholds for signal quality (#5903) Co-authored-by: Claude Opus 4.8 --- .../meshtastic/core/model/ChannelOption.kt | 48 +++++++---- .../core/ui/component/BuildNodeDescription.kt | 4 +- .../core/ui/component/LoraSignalIndicator.kt | 71 ++++++++++----- .../core/ui/component/MeshtasticAppShell.kt | 22 ++++- .../meshtastic/core/ui/component/NodeItem.kt | 7 +- .../core/ui/component/NodeItemCompact.kt | 7 +- .../core/ui/component/SignalInfo.kt | 3 +- .../core/ui/util/LocalModemPreset.kt | 29 +++++++ .../ui/component/LoraSignalIndicatorTest.kt | 86 +++++++++++++++++++ .../car/service/CarStateCoordinator.kt | 8 +- .../feature/car/util/CarScreenDataBuilder.kt | 42 +++++---- .../car/util/CarScreenDataBuilderTest.kt | 68 +++++++-------- .../feature/node/metrics/SignalMetrics.kt | 2 +- 13 files changed, 295 insertions(+), 102 deletions(-) create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt create mode 100644 core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt index da6ae71cd..6bc13935d 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/ChannelOption.kt @@ -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 { diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt index 9e2c69614..5d672477c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt @@ -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())) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index f02c17f71..fedd89a60 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -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 + } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt index 9f6077e2d..7a99b8dd0 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/MeshtasticAppShell.kt @@ -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() + 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() } + } } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt index 4e4e16020..aa2542365 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt @@ -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), diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt index 85353bc19..229bc6b86 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt @@ -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( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index 4182ee20a..6e41b2354 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -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, diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt new file mode 100644 index 000000000..ce9212cce --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalModemPreset.kt @@ -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 . + */ +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 { null } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt new file mode 100644 index 000000000..1ce0b4557 --- /dev/null +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt @@ -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 . + */ +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) + } +} diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt index b47f1a81c..ec2833bff 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/service/CarStateCoordinator.kt @@ -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 diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt index a7d26846c..17666c85b 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt @@ -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): List = nodes - .map(::buildNodeUi) + fun sortNodes(nodes: Collection, modemPreset: ModemPreset? = null): List = nodes + .map { buildNodeUi(it, modemPreset) } .sortedWith(compareByDescending { it.isOnline }.thenByDescending { it.lastHeard }) /** Builds ordered conversation list: sorted by most recent message time descending. */ fun sortConversations(conversations: List): List = 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 + } } /** diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt index 340b58fa8..e24869747 100644 --- a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt +++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt @@ -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() diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 5c4638537..1007932c2 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -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) } } }