feat(node): label SNR quality on the Node Details signal row (#6703)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James BeechingandClaude Sonnet 5 authored and GitHub committed 2026-08-17 17:43:07 +00:00
1 parent 6cd7a327b2
commit c8d9795885
11 files changed
+104 -2

No files matched your search

@@ -93,6 +93,7 @@ internal fun InfoItem(
icon: ImageVector,
modifier: Modifier = Modifier,
valueStyle: TextStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
valueColor: Color = MaterialTheme.colorScheme.onSurface,
iconTint: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
iconSize: Dp = 14.dp,
onClick: (() -> Unit)? = null,
@@ -130,7 +131,7 @@ internal fun InfoItem(
)
}
Spacer(Modifier.height(4.dp))
Text(text = value, style = valueStyle, color = MaterialTheme.colorScheme.onSurface)
Text(text = value, style = valueStyle, color = valueColor)
}
}
@@ -82,6 +82,7 @@ import org.meshtastic.core.resources.transport
import org.meshtastic.core.resources.uptime
import org.meshtastic.core.resources.user_id
import org.meshtastic.core.ui.component.SignedNodeDialog
import org.meshtastic.core.ui.component.determineSignalQuality
import org.meshtastic.core.ui.component.transportInfo
import org.meshtastic.core.ui.icon.ArrowCircleUp
import org.meshtastic.core.ui.icon.DeviceNumbers
@@ -98,6 +99,7 @@ import org.meshtastic.core.ui.icon.Snr
import org.meshtastic.core.ui.icon.Verified
import org.meshtastic.core.ui.icon.role
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.core.ui.util.createClipEntry
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.proto.MeshPacket.TransportMechanism
@@ -292,9 +294,12 @@ private fun SignalRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
val snr = node.snrOrNull
if (snr != null) {
val quality = determineSignalQuality(snr, LocalModemPreset.current)
// Value-before-quality with " · " matches the node-list signal pill in SignalInfo.kt.
InfoItem(
label = stringResource(Res.string.snr),
value = MetricFormatter.snr(snr),
value = "${MetricFormatter.snr(snr)} · ${stringResource(quality.nameRes)}",
valueColor = quality.color(),
icon = MeshtasticIcons.Snr,
modifier = Modifier.weight(1f),
)
@@ -303,6 +308,7 @@ private fun SignalRow(node: Node) {
}
val rssi = node.rssiOrNull
if (rssi != null) {
// No quality word here: RSSI alone can't be rated without the noise floor - see determineSignalQuality.
InfoItem(
label = stringResource(Res.string.rssi),
value = MetricFormatter.rssi(rssi),
@@ -0,0 +1,95 @@
/*
* 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.feature.node.component
import androidx.compose.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.v2.runComposeUiTest
import org.jetbrains.compose.resources.StringResource
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.bad
import org.meshtastic.core.resources.fair
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.good
import org.meshtastic.core.resources.none_quality
import org.meshtastic.core.resources.snr
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.LocalModemPreset
import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
import kotlin.test.Test
/**
* Node Details' SignalRow reuses the already-tested [determineSignalQuality] to label SNR (see
* [LoraSignalIndicatorTest] for the underlying threshold coverage). RSSI intentionally keeps showing the raw value
* only - RSSI alone cannot indicate quality without the noise floor.
*/
@OptIn(ExperimentalTestApi::class)
class SignalRowQualityLabelTest {
// Boundary values relative to ModemPreset.LONG_FAST's -17.5 dB demod floor, mirroring LoraSignalIndicatorTest.
private val preset = ModemPreset.LONG_FAST
@Test
fun signalRow_labelsGoodSnr() = runComposeUiTest {
assertSnrLabel(snr = -17f, expectedQualityRes = Res.string.good)
}
@Test
fun signalRow_labelsFairSnr() = runComposeUiTest {
assertSnrLabel(snr = -17.5f, expectedQualityRes = Res.string.fair)
}
@Test
fun signalRow_labelsBadSnr() = runComposeUiTest { assertSnrLabel(snr = -23f, expectedQualityRes = Res.string.bad) }
@Test
fun signalRow_labelsNoneSnr() = runComposeUiTest {
assertSnrLabel(snr = -30f, expectedQualityRes = Res.string.none_quality)
}
@Test
fun signalRow_rssiShowsOnlyTheRawValue() = runComposeUiTest {
setNodeDetails(Node(num = 1, snr = -17f, rssi = -100, hopsAway = 0))
onNodeWithText(MetricFormatter.rssi(-100)).assertExists()
}
@Test
fun signalRow_hidesWhenNodeHasNoSnrReading() = runComposeUiTest {
// hopsAway = 0 so SignalRow itself renders; default snr is the SNR_UNSET sentinel -> snrOrNull is null.
setNodeDetails(Node(num = 1, hopsAway = 0))
onNodeWithText(getString(Res.string.snr)).assertDoesNotExist()
}
private fun ComposeUiTest.assertSnrLabel(snr: Float, expectedQualityRes: StringResource) {
// hopsAway = 0 - matches MainNodeDetails' guard for rendering SignalRow at all.
setNodeDetails(Node(num = 1, snr = snr, rssi = -100, hopsAway = 0))
// Value-before-quality order matches the node-list signal pill in SignalInfo.kt.
val expected = "${MetricFormatter.snr(snr)} · ${getString(expectedQualityRes)}"
onNodeWithText(expected).assertExists()
}
private fun ComposeUiTest.setNodeDetails(node: Node) = setContent {
CompositionLocalProvider(LocalModemPreset provides preset) {
AppTheme { Surface { NodeDetailsSection(node = node) } }
}
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 60 KiB