mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-25 22:15:33 -04:00
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:
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user