feat(node): label SNR quality on the Node Details signal row (#6703)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 60 KiB |