fix(ui): give rx_snr real presence semantics end to end (#6523)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Opus 5 authored and GitHub committed 2026-07-30 16:40:41 +00:00
1 parent 4846425ff1
commit 2d20cd8a47
46 files changed
+2194 -148

No files matched your search

@@ -122,7 +122,7 @@ fun DiscoveryOsmMap(
position = nodeGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = node.longName ?: node.shortName ?: "Unknown"
snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}"
snippet = "SNR: ${MetricFormatter.snr(node.snr)} / RSSI: ${MetricFormatter.rssi(node.rssi)}"
val drawableId =
if (node.isSensorNode) {
@@ -126,10 +126,10 @@ data class GetNodeDetailsResponse(
val hardwareModel: String,
/** Firmware version string. */
val firmwareVersion: String,
/** Signal-to-noise ratio of strongest signal. */
val snr: Float,
/** Received signal strength indicator in dB. */
val rssi: Int,
/** Signal-to-noise ratio in dB of the strongest signal, or null if this node has no reading. */
val snr: Float?,
/** Received signal strength indicator in dBm, or null if this node has no reading. */
val rssi: Int?,
/** Number of hops away from local node (-1 if unknown). */
val hopsAway: Int,
/** Channel index this node is on. */
@@ -126,7 +126,7 @@ fun DiscoveryGoogleMap(
MarkerComposable(
state = rememberUpdatedMarkerState(position = nodeLatLng),
title = node.longName ?: node.shortName ?: "Unknown",
snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}",
snippet = "SNR: ${MetricFormatter.snr(node.snr)} / RSSI: ${MetricFormatter.rssi(node.rssi)}",
) {
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
}
@@ -45,7 +45,12 @@ object MetricFormatter {
fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
/**
* Formats a signal-to-noise ratio, or [UNKNOWN_VALUE] when the packet carried no measurement. 0 dB is a legitimate
* reading, so it must never stand in for a missing one.
*/
fun snr(value: Float?, decimalPlaces: Int = 1): String =
if (value == null) UNKNOWN_VALUE else "${NumberFormatter.format(value, decimalPlaces)} dB"
/**
* Formats a received signal strength, or [UNKNOWN_VALUE] when the radio reported none. 0 dBm is a legitimate
@@ -74,6 +74,18 @@ class MetricFormatterTest {
@Test
fun snr() {
assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
assertEquals("-12.5 dB", MetricFormatter.snr(-12.5f))
}
@Test
fun snrAbsentIsUnknown() {
assertEquals("", MetricFormatter.snr(null))
}
@Test
fun snrZeroIsARealReading() {
// Must not render as unknown: 0 dB is a signal at the noise floor, not a missing measurement.
assertEquals("0.0 dB", MetricFormatter.snr(0f))
}
@Test
@@ -228,8 +228,9 @@ class AiFunctionProviderImpl(
voltage = node.deviceMetrics.voltage,
hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
firmwareVersion = node.metadata?.firmware_version ?: "Unknown",
snr = node.snr,
rssi = node.rssi,
// Never surface the unset sentinels to a model — Float.MAX_VALUE reads as a superb signal.
snr = node.snrOrNull,
rssi = node.rssiOrNull,
hopsAway = node.hopsAway,
channel = node.channel,
lastHeard = node.lastHeard.toLong() * MS_PER_SEC,
@@ -159,10 +159,10 @@ data class NodeDetails(
val hardwareModel: String,
/** Firmware version string. */
val firmwareVersion: String,
/** Signal-to-noise ratio of the strongest received signal. */
val snr: Float,
/** Received signal strength indicator in dB. */
val rssi: Int,
/** Signal-to-noise ratio in dB of the strongest received signal, or null if this node has no reading. */
val snr: Float?,
/** Received signal strength indicator in dBm, or null if this node has no reading. */
val rssi: Int?,
/** Number of hops away from the local node (-1 if unknown). */
val hopsAway: Int,
/** Channel index this node is on. */
@@ -44,6 +44,7 @@ import org.meshtastic.core.model.textMentionsNode
import org.meshtastic.core.model.util.MeshDataMapper
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.isValidCodePoint
import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
@@ -250,7 +251,13 @@ class MeshDataHandlerImpl(
// Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification.
if (beacon?.offer_channel == null) return
val offer =
MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi)
MeshBeaconOffer(
fromNodeNum = packet.from,
beacon = beacon,
// [MeshBeaconOffer.snr] is not nullable, so absent narrows to 0f. See [snrOrNull].
snr = packet.snrOrNull() ?: 0f,
rssi = packet.rx_rssi,
)
if (meshBeaconRepository.add(offer)) {
radioInterfaceService.launchSessionWork(scope, session) {
notificationManager.dispatch(
@@ -583,7 +590,8 @@ class MeshDataHandlerImpl(
user = fromNode.user,
emoji = emoji,
timestamp = nowMillis,
snr = packet.rx_snr,
// [Reaction.snr] is not nullable, so absent narrows to 0f here. See [snrOrNull].
snr = packet.snrOrNull() ?: 0f,
rssi = packet.rx_rssi,
hopsAway =
if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.MeshLog
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.model.util.toOneLineString
import org.meshtastic.core.model.util.toPIIString
import org.meshtastic.core.repository.FromRadioPacketHandler
@@ -319,7 +320,8 @@ class MeshMessageProcessorImpl(
lastHeard = packet.rxTimeOrNull()?.let(::clampTimestampToNow) ?: node.lastHeard,
viaMqtt = viaMqtt,
lastTransport = packet.transport_mechanism.value,
snr = if (updateRadioMetrics) packet.rx_snr else node.snr,
// A packet carrying no snr must not clobber the node's last real reading either.
snr = if (updateRadioMetrics) packet.snrOrNull() ?: node.snr else node.snr,
// A packet carrying no rssi must not clobber the node's last real reading.
rssi = if (updateRadioMetrics) packet.rx_rssi ?: node.rssi else node.rssi,
hopsAway = hopsAway,
@@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
import co.touchlab.kermit.Logger
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.repository.NeighborInfoHandler
import org.meshtastic.core.repository.NodeManager
import org.meshtastic.core.repository.NodeRepository
@@ -56,7 +57,7 @@ class NeighborInfoHandlerImpl(
ni.neighbors.joinToString("\n") { n ->
val user = nodeRepository.getUser(n.node_id)
val name = "${user.long_name} (${user.short_name})"
"$name (SNR: ${n.snr})"
"$name (SNR: ${MetricFormatter.snr(n.snr)})"
}
val fromUser = nodeRepository.getUser(from)
File diff suppressed because it is too large. Load diff
@@ -130,8 +130,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 48, to = 49),
AutoMigration(from = 49, to = 50),
AutoMigration(from = 50, to = 51),
AutoMigration(from = 51, to = 52),
],
version = 51,
version = 52,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -102,7 +102,8 @@ data class Packet(
@ColumnInfo(name = "data") val data: DataPacket,
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
/** Null when the packet carried no snr. Rows written before schema 52 store 0 for both absent and 0 dB. */
@ColumnInfo(name = "snr") val snr: Float? = null,
/** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
@ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@@ -162,7 +163,8 @@ data class ReactionEntity(
@ColumnInfo(name = "user_id") val userId: String,
val emoji: String,
val timestamp: Long,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
/** Null when the packet carried no snr. Rows written before schema 52 store 0 for both absent and 0 dB. */
@ColumnInfo(name = "snr") val snr: Float? = null,
/** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
@ColumnInfo(name = "rssi") val rssi: Int? = null,
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@@ -108,6 +108,29 @@ class MeshtasticDatabaseMigrationTest {
}
}
@Test
fun snrColumnsGoNullableWithoutLosingRows() = runTest {
helper.createDatabase(SNR_NULLABLE_FROM_VERSION).use { connection ->
connection.execSQL(
"INSERT INTO packet (uuid, myNodeNum, port_num, contact_key, received_time, read, data, snr, rssi) " +
"VALUES (1, 42, 1, '0^all', 1000, 1, '{}', 0.0, -70)",
)
connection.execSQL(
"INSERT INTO reactions (myNodeNum, reply_id, user_id, emoji, timestamp, snr, rssi) " +
"VALUES (42, 7, '!abc', 'X', 2000, -12.5, -70)",
)
}
helper.runMigrationsAndValidate(SNR_NULLABLE_TO_VERSION, emptyList()).use { connection ->
// A stored 0 dB must survive the recreate as 0, not become NULL: it is a real reading.
assertEquals(listOf("0.0"), queryColumn(connection, "SELECT snr FROM packet"))
assertEquals(listOf("-12.5"), queryColumn(connection, "SELECT snr FROM reactions"))
// A NULL is now storable where the column was previously NOT NULL DEFAULT 0.
connection.execSQL("UPDATE packet SET snr = NULL WHERE uuid = 1")
assertEquals(listOf(null), queryColumn(connection, "SELECT snr FROM packet"))
}
}
/** Reads one column of every row as a string, with SQL NULL surfaced as Kotlin null. */
private fun queryColumn(connection: SQLiteConnection, sql: String): List<String?> =
connection.prepare(sql).use { statement ->
@@ -130,5 +153,7 @@ class MeshtasticDatabaseMigrationTest {
const val EARLIEST_SCHEMA_VERSION = 3
const val RSSI_NULLABLE_FROM_VERSION = 50
const val RSSI_NULLABLE_TO_VERSION = 51
const val SNR_NULLABLE_FROM_VERSION = 51
const val SNR_NULLABLE_TO_VERSION = 52
}
}
@@ -23,6 +23,7 @@ import okio.BufferedSink
import org.koin.core.annotation.Single
import org.meshtastic.core.model.Position
import org.meshtastic.core.model.util.positionToMeter
import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.proto.PortNum
@@ -76,9 +77,12 @@ constructor(
}
}
// Rows are limited to receptions that carried an SNR measurement. Gating on `snrOrNull()` rather than
// `rx_snr != 0f` keeps a genuine 0 dB reading in the export.
val rxSnrOrNull = proto.snrOrNull()
if (
(filterPortnum == null || (proto.decoded?.portnum?.value ?: 0) == filterPortnum) &&
proto.rx_snr != 0.0f
rxSnrOrNull != null
) {
val timeZone = TimeZone.currentSystemDefault()
val rxDateTimeObj = Instant.fromEpochMilliseconds(packet.received_date).toLocalDateTime(timeZone)
@@ -97,7 +101,7 @@ constructor(
val rxLat = rxPos?.latitude ?: ""
val rxLong = rxPos?.longitude ?: ""
val rxAlt = rxPos?.altitude ?: ""
val rxSnr = proto.rx_snr
val rxSnr = rxSnrOrNull
val dist =
if (senderPos == null || rxPos == null) {
@@ -51,7 +51,8 @@ data class DataPacket(
var channel: Int = 0, // channel index
var wantAck: Boolean = true, // If true, the receiver should send an ack back
var hopStart: Int = 0,
var snr: Float = 0f,
/** Signal-to-noise ratio in dB, or null when the packet carried no measurement. 0 dB is a valid reading. */
var snr: Float? = null,
/** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
var rssi: Int? = null,
var replyId: Int? = null, // If this is a reply to a previous message, this is the ID of that message
@@ -27,10 +27,15 @@ import org.meshtastic.proto.MeshBeacon
*
* @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned).
* @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer.
* @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown).
* @param snr Signal-to-noise ratio of the received beacon packet, in dB, or null when the radio reported none.
* @param rssi Received signal strength of the beacon packet, in dBm, or null when the radio reported none.
*/
data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int? = null) {
data class MeshBeaconOffer(
val fromNodeNum: Int,
val beacon: MeshBeacon,
val snr: Float? = null,
val rssi: Int? = null,
) {
/** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */
val key: String
get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}"
@@ -55,9 +60,10 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
/**
* Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node
* number, or an undecodable beacon payload). An unparseable snr falls back to 0 and an unparseable rssi to
* absent — they are non-critical display metrics, not identity, so a bad numeric there does not discard an
* otherwise-valid invitation. An absent rssi encodes as `null`, which [String.toIntOrNull] round-trips back.
* number, or an undecodable beacon payload). An unparseable snr or rssi falls back to absent — they are
* non-critical display metrics, not identity, so a bad numeric there does not discard an otherwise-valid
* invitation. An absent value encodes as `null`, which [String.toFloatOrNull]/[String.toIntOrNull] round-trip
* back to null.
*/
@Suppress("ReturnCount")
fun decode(record: String): MeshBeaconOffer? {
@@ -66,7 +72,7 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
val node = parts[0].toIntOrNull() ?: return null
val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null
val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null
return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull())
return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull(), parts[2].toIntOrNull())
}
}
}
@@ -160,7 +160,8 @@ data class Message(
val routingError: Int,
val packetId: Int,
val emojis: List<Reaction>,
val snr: Float,
/** Signal-to-noise ratio in dB, or null when the packet carried no measurement. 0 dB is a valid reading. */
val snr: Float?,
/** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
val rssi: Int?,
val hopsAway: Int,
@@ -17,6 +17,7 @@
package org.meshtastic.core.model
import co.touchlab.kermit.Logger
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
@@ -43,7 +44,7 @@ fun NeighborInfo.getNeighborInfoResponse(getUser: (nodeNum: Int) -> String, head
append("")
append(getUser(n.node_id))
append(" (SNR: ")
append(n.snr)
append(MetricFormatter.snr(n.snr))
append(")\n")
}
}
@@ -92,6 +92,20 @@ data class Node(
val mismatchKey
get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING
/**
* Last measured SNR in dB, or null when this node has no reading yet ([snr] still holds [SNR_UNSET]).
*
* Every read of [snr] should go through this: 0 dB is a real, good reading, and the raw sentinel rates as an
* *excellent* signal if it reaches the preset-relative quality bands. Threshold comparisons such as `snr < 100f`
* are not equivalent — they also discard any genuine reading at or above the threshold.
*/
val snrOrNull: Float?
get() = snr.takeIf { it != SNR_UNSET }
/** Last measured RSSI in dBm, or null when this node has no reading yet. 0 dBm is a real reading. */
val rssiOrNull: Int?
get() = rssi.takeIf { it != RSSI_UNSET }
val hasEnvironmentMetrics: Boolean
get() = environmentMetrics != EnvironmentMetrics()
@@ -178,6 +192,14 @@ data class Node(
/** Size (in bytes) of a Curve25519 public key as used by meshtastic firmware. */
const val PUBLIC_KEY_SIZE: Int = 32
/**
* Sentinels stored when a node has no radio-metric reading. They exist because [snr]/[rssi] are not nullable
* (the Room columns behind them are NOT NULL); resolve them with [snrOrNull]/[rssiOrNull] rather than comparing
* against them at call sites.
*/
const val SNR_UNSET: Float = Float.MAX_VALUE
const val RSSI_UNSET: Int = Int.MAX_VALUE
val ERROR_BYTE_STRING: ByteString = ByteArray(PUBLIC_KEY_SIZE) { 0 }.toByteString()
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
@@ -24,7 +24,10 @@ data class Reaction(
val user: User,
val emoji: String,
val timestamp: Long,
val snr: Float,
/**
* Signal-to-noise ratio in dB, or null when the packet carried no measurement (locally sent reactions included).
*/
val snr: Float?,
/** Received signal strength, or null when the radio did not report one (locally sent reactions included). */
val rssi: Int?,
val hopsAway: Int,
@@ -104,10 +104,26 @@ fun MeshPacket.isLora(): Boolean = transport_mechanism == MeshPacket.TransportMe
* Arrival time in epoch seconds, or null when the radio had no clock at reception.
*
* Firmware that gained explicit presence omits the field; older firmware still sends 0 for the same state. Both mean
* unknown — a 1970 arrival time is never a genuine reading.
* unknown — a 1970 arrival time is never a genuine reading. Folding 0 is safe here for exactly that reason; see
* [snrOrNull] for the fields where it is not.
*/
fun MeshPacket.rxTimeOrNull(): Int? = rx_time?.takeIf { it != 0 }
/**
* Signal-to-noise ratio in dB for this reception, or null when the packet carries no SNR measurement (it did not arrive
* over LoRa, or the radio reported none).
*
* Deliberately does NOT fold 0 the way [rxTimeOrNull] does: 0 dB is a genuine, common reading — a signal at the noise
* floor, comfortably demodulable on every preset — so treating it as "absent" would hide real measurements and, worse,
* discard the only zero that can ever reach us. Under proto3 implicit presence a field at its zero value is never put
* on the wire, so an SNR-less packet from firmware predating the optional conversion already decodes to null for free.
* A 0 that survives to this accessor was written explicitly and means 0 dB.
*
* Presence cannot be inferred from [isLora] instead: `transport_mechanism` defaults to `TRANSPORT_INTERNAL` (0), so
* firmware that never sets it would have every reading suppressed.
*/
fun MeshPacket.snrOrNull(): Float? = rx_snr
/** Returns true if this packet is a direct LoRa signal (not MQTT, and hop count matches). */
fun MeshPacket.isDirectSignal(): Boolean =
rxTimeOrNull() != null && hop_start == hop_limit && via_mqtt != true && isLora()
@@ -44,7 +44,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
channel = if (packet.pki_encrypted == true) NodeAddress.PKC_CHANNEL_INDEX else packet.channel,
wantAck = packet.want_ack == true,
hopStart = packet.hop_start,
snr = packet.rx_snr,
snr = packet.snrOrNull(),
rssi = packet.rx_rssi,
replyId = decoded.reply_id,
relayNode = packet.relay_node,
@@ -0,0 +1,73 @@
/*
* 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.model.util
import org.meshtastic.proto.MeshPacket
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
/**
* Guards the presence policy for `rx_snr`: absent means null and nothing else. Unlike [rxTimeOrNull], a zero must never
* be folded into "unknown" — 0 dB is a real measurement. See [snrOrNull].
*
* The proto-absent case is not asserted here because it is not yet constructible: `rx_snr` is still a non-null `float`
* upstream, so [snrOrNull] cannot return null for any packet this test could build. What these tests do lock down is
* the half that can regress today — that a zero is never folded — which is exactly what breaks if the [rxTimeOrNull]
* pattern is copied over. Null *handling* is covered where a null is representable: `MetricFormatterTest`
* (`snrAbsentIsUnknown`) and `LoraSignalIndicatorUiTest` (`snrRendersNothingWhenAbsent`,
* `loraSignalIndicatorShowsUnknownWhenSnrIsAbsent`).
*/
class SnrExtensionsTest {
private fun loraPacket(snr: Float) = MeshPacket(
rx_time = 1_700_000_000,
rx_snr = snr,
hop_start = 3,
hop_limit = 3,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
@Test
fun `snrOrNull reports a negative reading`() {
// The common case: SNR below the noise floor but still demodulable on a long preset.
assertEquals(-12.5f, loraPacket(-12.5f).snrOrNull())
}
@Test
fun `snrOrNull reports a positive reading`() {
assertEquals(6.5f, loraPacket(6.5f).snrOrNull())
}
@Test
fun `snrOrNull treats a true zero as a measurement rather than as absent`() {
// The whole point of the policy. A zero-folding `takeIf { it != 0f }` here would silently discard a signal
// sitting exactly at the noise floor — comfortably demodulable on every preset — and would also throw away
// the only zero that can reach us, since a zero from firmware predating the optional conversion is never put
// on the wire and so already decodes to null.
val snr = loraPacket(0f).snrOrNull()
assertNotNull(snr)
assertEquals(0f, snr)
}
@Test
fun `snrOrNull does not conflate a zero reading with an unknown one`() {
// Regression guard for the rx_time seam's pattern being copied over verbatim.
assertEquals(0f, loraPacket(0f).snrOrNull())
assertEquals(null, MeshPacket().rxTimeOrNull())
}
}
@@ -37,7 +37,6 @@ import org.meshtastic.proto.Config.LoRaConfig.ModemPreset
private const val MILLIS_PER_SECOND = 1000L
private const val MAX_BATTERY_PERCENT = 100
private const val SNR_UNSET_THRESHOLD = 100f
/** Pre-resolved localized strings for TalkBack node descriptions. */
@Immutable
@@ -82,8 +81,7 @@ internal fun buildNodeDescription(
hopsAway: Int,
batteryLevel: Int?,
distance: String?,
snr: Float,
rssi: Int,
snr: Float?,
viaMqtt: Boolean,
strings: NodeDescriptionStrings,
lastHeardIsRelative: Boolean = true,
@@ -122,7 +120,9 @@ internal fun buildNodeDescription(
append(", ")
append(strings.distanceAway.replace("%s", it))
}
if (hopsAway == 0 && !viaMqtt && snr < SNR_UNSET_THRESHOLD && rssi < 0) {
// Rated from SNR alone: RSSI cannot indicate demodulability without the noise floor, and the old `rssi < 0` gate
// suppressed the announcement for a genuine 0 dBm reading.
if (hopsAway == 0 && !viaMqtt && snr != null) {
val quality = determineSignalQuality(snr, modemPreset)
append(", ")
append(strings.signal.replace("%s", quality.name.lowercase()))
@@ -20,11 +20,7 @@ package org.meshtastic.core.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
@@ -56,6 +52,7 @@ import org.meshtastic.core.resources.rssi
import org.meshtastic.core.resources.signal
import org.meshtastic.core.resources.signal_quality
import org.meshtastic.core.resources.snr
import org.meshtastic.core.resources.unknown
import org.meshtastic.core.ui.theme.StatusColors.StatusGreen
import org.meshtastic.core.ui.theme.StatusColors.StatusOrange
import org.meshtastic.core.ui.theme.StatusColors.StatusRed
@@ -88,60 +85,22 @@ enum class Quality(
GOOD(Res.string.good, Res.drawable.ic_signal_cellular_4_bar, { colorScheme.StatusGreen }),
}
/**
* Displays the `snr` and `rssi` color coded based on the signal quality, along with a human readable description and
* related icon.
*/
@OptIn(ExperimentalLayoutApi::class)
@Composable
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, modemPreset = modemPreset)
Rssi(rssi)
Text(
text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
style = MaterialTheme.typography.labelSmall,
maxLines = 1,
)
Icon(
modifier = Modifier.size(SIZE_ICON_DP.dp),
imageVector = vectorResource(quality.icon),
contentDescription = stringResource(Res.string.signal_quality),
tint = quality.color(),
)
}
}
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?, modemPreset: ModemPreset? = LocalModemPreset.current) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Snr(snr, modemPreset = modemPreset)
Rssi(rssi)
}
}
/** Displays a human readable description and icon representing the signal quality. */
/**
* Displays a human readable description and icon representing the signal quality.
*
* A null [snr] means the packet carried no measurement, which is rendered as "Unknown" in a neutral tint. It must not
* fall through to [Quality.NONE] — that band means "measured, and too weak to demodulate", a different claim.
*/
@Composable
fun LoraSignalIndicator(
snr: Float,
snr: Float?,
modifier: Modifier = Modifier,
modemPreset: ModemPreset? = LocalModemPreset.current,
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
val quality = determineSignalQuality(snr, modemPreset)
val quality = snr?.let { determineSignalQuality(it, modemPreset) }
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
@@ -149,20 +108,22 @@ fun LoraSignalIndicator(
) {
Icon(
modifier = Modifier.size(SIZE_ICON_DP.dp),
imageVector = vectorResource(quality.icon),
imageVector = vectorResource(quality?.icon ?: Res.drawable.ic_signal_cellular_alt),
contentDescription = stringResource(Res.string.signal_quality),
tint = quality.color(),
tint = quality?.color?.invoke() ?: MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "${stringResource(Res.string.signal)} ${stringResource(quality.nameRes)}",
text = "${stringResource(Res.string.signal)} " + stringResource(quality?.nameRes ?: Res.string.unknown),
style = MaterialTheme.typography.labelSmall,
color = contentColor,
)
}
}
/** Renders nothing when [snr] is absent — 0 dB is a real reading, so it must not stand in for "no reading". */
@Composable
fun Snr(snr: Float, modifier: Modifier = Modifier, modemPreset: ModemPreset? = LocalModemPreset.current) {
fun Snr(snr: Float?, modifier: Modifier = Modifier, modemPreset: ModemPreset? = LocalModemPreset.current) {
if (snr == null) return
val color: Color = determineSignalQuality(snr, modemPreset).color.invoke()
Text(
@@ -145,8 +145,7 @@ fun NodeItem(
hopsAway = thatNode.hopsAway,
batteryLevel = thatNode.batteryLevel,
distance = distance,
snr = thatNode.snr,
rssi = thatNode.rssi,
snr = thatNode.snrOrNull,
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
modemPreset = modemPreset,
@@ -312,9 +311,9 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
if (thatNode.hopsAway > 0) {
add { HopsInfo(hops = thatNode.hopsAway, contentColor = contentColor) }
} else if (thatNode.hopsAway == 0 && !thatNode.viaMqtt) {
val showSnr = thatNode.snr < 100f
val showRssi = thatNode.rssi < 0
if (showSnr || showRssi) {
val snr = thatNode.snrOrNull
val rssi = thatNode.rssiOrNull
if (snr != null || rssi != null) {
signalChip = {
// Full-width row: SNR left, RSSI center, quality right.
Row(
@@ -322,10 +321,10 @@ private fun NodeSignalRow(thatNode: Node, isThisNode: Boolean, contentColor: Col
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (showSnr) Snr(thatNode.snr)
if (showRssi) Rssi(thatNode.rssi)
if (showSnr && showRssi) {
val quality = determineSignalQuality(thatNode.snr, LocalModemPreset.current)
Snr(snr)
Rssi(rssi)
if (snr != null) {
val quality = determineSignalQuality(snr, LocalModemPreset.current)
IconInfo(
icon = vectorResource(quality.icon),
contentDescription = stringResource(Res.string.signal_quality),
@@ -155,8 +155,7 @@ fun NodeItemCompact(
hopsAway = thatNode.hopsAway,
batteryLevel = thatNode.batteryLevel,
distance = distance,
snr = thatNode.snr,
rssi = thatNode.rssi,
snr = thatNode.snrOrNull,
viaMqtt = thatNode.viaMqtt,
strings = a11yStrings,
lastHeardIsRelative = lastHeardIsRelative,
@@ -350,10 +349,10 @@ 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, LocalModemPreset.current)
// Signal quality, rated from SNR alone — RSSI is not part of the rating (#5446), so it must not gate it.
val directSnr = thatNode.snrOrNull?.takeIf { thatNode.hopsAway == 0 && !thatNode.viaMqtt }
if (showSignal && directSnr != null) {
val quality = determineSignalQuality(directSnr, LocalModemPreset.current)
add(
@Composable {
IconInfo(
@@ -42,17 +42,21 @@ 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
/**
* Renders the node's signal quality, or nothing when it has no SNR reading to rate.
*
* Presence comes from [Node.snrOrNull]/[Node.rssiOrNull], not from threshold comparisons: the previous `rssi < 0` gate
* hid the whole row for a genuine 0 dBm reading, and `snr < 100f` would have hidden any reading at or above 100 dB.
*/
@Composable
fun SignalInfo(
modifier: Modifier = Modifier,
node: Node,
@Suppress("UNUSED_PARAMETER") contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
if (node.snr < MAX_VALID_SNR && node.rssi < MAX_VALID_RSSI) {
val quality = determineSignalQuality(node.snr, LocalModemPreset.current)
val snr = node.snrOrNull
if (snr != null) {
val quality = determineSignalQuality(snr, LocalModemPreset.current)
val signalColor = quality.color.invoke()
Row(
modifier = modifier,
@@ -67,9 +71,8 @@ fun SignalInfo(
)
Text(
text =
"${MetricFormatter.snr(
node.snr,
)} · ${MetricFormatter.rssi(node.rssi)} · ${stringResource(quality.nameRes)}",
"${MetricFormatter.snr(snr)} · ${MetricFormatter.rssi(node.rssiOrNull)} · " +
stringResource(quality.nameRes),
style =
MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Bold,
@@ -48,8 +48,7 @@ class BuildNodeDescriptionTest {
hopsAway: Int = 0,
batteryLevel: Int? = null,
distance: String? = null,
snr: Float = Float.MAX_VALUE,
rssi: Int = 0,
snr: Float? = null,
viaMqtt: Boolean = false,
lastHeardIsRelative: Boolean = true,
): String = buildNodeDescription(
@@ -62,7 +61,6 @@ class BuildNodeDescriptionTest {
batteryLevel = batteryLevel,
distance = distance,
snr = snr,
rssi = rssi,
viaMqtt = viaMqtt,
strings = testStrings,
lastHeardIsRelative = lastHeardIsRelative,
@@ -157,32 +155,33 @@ class BuildNodeDescriptionTest {
// ---- Signal ----
@Test
fun signal_hidden_when_snr_is_max_float() {
val result = describe(snr = Float.MAX_VALUE, rssi = -100, hopsAway = 0, viaMqtt = false)
fun signal_hidden_when_snr_is_absent() {
val result = describe(snr = null, hopsAway = 0, viaMqtt = false)
assertFalse(result.contains("signal"))
}
@Test
fun signal_hidden_when_via_mqtt() {
val result = describe(snr = -5f, rssi = -100, hopsAway = 0, viaMqtt = true)
val result = describe(snr = -5f, hopsAway = 0, viaMqtt = true)
assertFalse(result.contains("signal"))
}
@Test
fun signal_hidden_when_hops_greater_than_zero() {
val result = describe(snr = -5f, rssi = -100, hopsAway = 1, viaMqtt = false)
val result = describe(snr = -5f, hopsAway = 1, viaMqtt = false)
assertFalse(result.contains("signal"))
}
@Test
fun signal_hidden_when_rssi_not_negative() {
val result = describe(snr = -5f, rssi = 0, hopsAway = 0, viaMqtt = false)
assertFalse(result.contains("signal"))
fun signal_shown_for_a_zero_snr_reading() {
// 0 dB is a real, strong reading. It was previously announced only when RSSI happened to be negative.
val result = describe(snr = 0f, hopsAway = 0, viaMqtt = false)
assertContains(result, "signal")
}
@Test
fun signal_shown_when_direct_and_valid_values() {
val result = describe(snr = -5f, rssi = -100, hopsAway = 0, viaMqtt = false)
val result = describe(snr = -5f, hopsAway = 0, viaMqtt = false)
assertContains(result, "signal")
}
}
@@ -77,6 +77,22 @@ class LoraSignalIndicatorTest {
assertEquals(Quality.NONE, determineSignalQuality(snr = -30f, modemPreset = preset)) // < limit-7.5
}
@Test
fun `a zero SNR reading is rated rather than treated as missing`() {
// 0 dB sits well above every preset's demod floor, so it is an excellent signal — not an absent one. If a
// presence check ever folds zero into "unknown", this is the reading that disappears.
assertEquals(Quality.GOOD, determineSignalQuality(snr = 0f, modemPreset = ModemPreset.LONG_FAST))
assertEquals(Quality.GOOD, determineSignalQuality(snr = 0f, modemPreset = ModemPreset.SHORT_FAST))
}
@Test
fun `absent SNR is not a quality band`() {
// Quality has no member for "no measurement": callers must pass a non-null SNR, and the composables render
// absence as Unknown rather than mapping it onto NONE (which asserts a measured, undemodulable signal).
assertEquals(4, Quality.entries.size)
assertEquals(listOf(Quality.NONE, Quality.BAD, Quality.FAIR, Quality.GOOD), Quality.entries.toList())
}
@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).
@@ -34,6 +34,29 @@ class LoraSignalIndicatorUiTest {
onNodeWithText("Signal strength -70 dBm").assertIsDisplayed()
}
@Test
fun snrRendersAZeroReading() = runComposeUiTest {
// 0 dB is a measurement and must be shown, not suppressed as "no reading".
setContent { AppTheme { Snr(snr = 0f) } }
onNodeWithText("SNR 0.00 dB").assertIsDisplayed()
}
@Test
fun snrRendersNothingWhenAbsent() = runComposeUiTest {
setContent { AppTheme { Snr(snr = null) } }
onNodeWithText("SNR 0.00 dB").assertDoesNotExist()
}
@Test
fun loraSignalIndicatorShowsUnknownWhenSnrIsAbsent() = runComposeUiTest {
// Absence must not render as "Signal None" — that band means a measured, undemodulable signal.
setContent { AppTheme { LoraSignalIndicator(snr = null) } }
onNodeWithText("Signal Unknown").assertIsDisplayed()
}
@Test
fun batteryUsesCallerProvidedUnknownLabel() = runComposeUiTest {
setContent { AppTheme { MaterialBatteryInfo(level = null, unknownLabel = "Unavailable") } }
@@ -63,7 +63,12 @@ enum class SignalQuality {
GOOD,
FAIR,
BAD,
/** Measured, but too weak to demodulate. Distinct from [UNKNOWN]. */
NONE,
/** No SNR reading for this node, so link quality cannot be rated. */
UNKNOWN,
}
data class TopologyHeader(val totalNodes: Int, val onlineNodes: Int, val meshName: String?)
@@ -105,6 +105,7 @@ class NodeDetailScreen(
SignalQuality.FAIR -> carContext.getString(R.string.car_signal_fair)
SignalQuality.BAD -> carContext.getString(R.string.car_signal_bad)
SignalQuality.NONE -> carContext.getString(R.string.car_signal_none)
SignalQuality.UNKNOWN -> carContext.getString(R.string.car_signal_unknown)
}
private fun formatLastHeard(epochMillis: Long): String {
@@ -53,7 +53,7 @@ internal object CarScreenDataBuilder {
userId = node.user.id,
longName = node.user.long_name.ifEmpty { "Unknown" },
shortName = node.user.short_name.ifEmpty { "?" },
signalQuality = determineSignalQuality(node.snr, modemPreset),
signalQuality = determineSignalQuality(node.snrOrNull, modemPreset),
batteryPercent = node.batteryLevel?.takeIf { it in 1..BATTERY_MAX_PERCENT },
isOnline = node.isOnline,
lastHeard = node.lastHeard.toLong() * SECONDS_TO_MILLIS,
@@ -72,9 +72,12 @@ internal object CarScreenDataBuilder {
/**
* 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.
*
* A null [snr] means no reading and yields [SignalQuality.UNKNOWN], never [SignalQuality.NONE] — the latter claims
* a measured, undemodulable link. 0 dB is a real reading and rates normally.
*/
fun determineSignalQuality(snr: Float, modemPreset: ModemPreset? = null): SignalQuality {
if (snr == Float.MAX_VALUE) return SignalQuality.NONE
fun determineSignalQuality(snr: Float?, modemPreset: ModemPreset? = null): SignalQuality {
if (snr == null) return SignalQuality.UNKNOWN
val limit = modemPreset.snrLimit
return when {
snr > limit + SNR_EXCELLENT_MARGIN -> SignalQuality.EXCELLENT
@@ -70,6 +70,7 @@ object NodeSubtitleFormatter {
SignalQuality.FAIR -> context.getString(R.string.car_signal_fair)
SignalQuality.BAD -> context.getString(R.string.car_signal_bad)
SignalQuality.NONE -> context.getString(R.string.car_signal_none)
SignalQuality.UNKNOWN -> context.getString(R.string.car_signal_unknown)
}
fun signalColor(quality: SignalQuality): CarColor = when (quality) {
@@ -78,5 +79,6 @@ object NodeSubtitleFormatter {
SignalQuality.FAIR -> CarColor.YELLOW
SignalQuality.BAD -> CarColor.RED
SignalQuality.NONE -> CarColor.SECONDARY
SignalQuality.UNKNOWN -> CarColor.SECONDARY
}
}
@@ -19,6 +19,7 @@
<string name="car_signal_fair">Fair</string>
<string name="car_signal_good">Good</string>
<string name="car_signal_none">None</string>
<string name="car_signal_unknown">Unknown</string>
<string name="car_status_battery">Battery</string>
<string name="car_status_last_heard">Last Heard</string>
<string name="car_status_offline">Offline</string>
@@ -56,10 +56,38 @@ class CarScreenDataBuilderTest {
// 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, ModemPreset.LONG_FAST)
fun `determineSignalQuality returns unknown when snr is absent`() {
// Absence is UNKNOWN, not NONE: NONE claims a measured link too weak to demodulate.
val quality = CarScreenDataBuilder.determineSignalQuality(null, ModemPreset.LONG_FAST)
assertEquals(SignalQuality.NONE, quality)
assertEquals(SignalQuality.UNKNOWN, quality)
}
@Test
fun `determineSignalQuality rates a zero snr reading`() {
val quality = CarScreenDataBuilder.determineSignalQuality(0f, ModemPreset.LONG_FAST)
assertEquals(SignalQuality.EXCELLENT, quality)
}
@Test
fun `buildNodeUi resolves an unset node snr to unknown`() {
// Exercises the production path: fails if buildNodeUi reverts to reading node.snr, which would feed the
// Float.MAX_VALUE sentinel into the bands and rate a node with no reading as EXCELLENT.
val node = Node(num = 1)
val ui = CarScreenDataBuilder.buildNodeUi(node, ModemPreset.LONG_FAST)
assertEquals(SignalQuality.UNKNOWN, ui.signalQuality)
}
@Test
fun `buildNodeUi rates a zero node snr reading`() {
val node = Node(num = 1, snr = 0f)
val ui = CarScreenDataBuilder.buildNodeUi(node, ModemPreset.LONG_FAST)
assertEquals(SignalQuality.EXCELLENT, ui.signalQuality)
}
@Test
@@ -46,6 +46,7 @@ import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.repository.MeshPrefs
@@ -267,8 +268,8 @@ class DiscoveryScanEngine(
mutex.withLock {
val node = collectedNodes.getOrPut(fromNum) { CollectedNodeData(nodeNum = fromNum) }
// Update signal info from the direct packet
if (meshPacket.rx_snr != 0f) node.snr = meshPacket.rx_snr
// Explicit presence: record a reported 0 dBm, skip only a genuinely absent one.
// Explicit presence: record a reported 0 dB/0 dBm, skip only a genuinely absent one.
meshPacket.snrOrNull()?.let { node.snr = it }
meshPacket.rx_rssi?.let { node.rssi = it }
node.hopCount = dataPacket.hopsAway.coerceAtLeast(0)
@@ -58,7 +58,7 @@ internal object DiscoveryReportFormatter {
fun formatNodeLine(node: DiscoveredNodeEntity): String = buildString {
append(node.longName ?: node.shortName ?: "!${node.nodeNum.toString(radix = 16)}")
append(" | ${node.neighborType}")
append(" | SNR: ${NumberFormatter.format(node.snr, 1)}")
append(" | SNR: ${MetricFormatter.snr(node.snr)}")
append(" | RSSI: ${MetricFormatter.rssi(node.rssi)}")
val distance = node.distanceFromUser
if (distance != null) {
@@ -99,7 +99,7 @@ internal fun MeshBeaconInvitationCard(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (offer.rssi != null || offer.snr != 0f) {
if (offer.rssi != null || offer.snr != null) {
Text(
text =
stringResource(
@@ -74,6 +74,68 @@ class MessageItemTest {
onNodeWithContentDescription("MQTT").assertIsDisplayed()
}
@Test
fun directMessageWithoutSnrDoesNotFabricateAZeroReading() = runComposeUiTest {
// Before DataPacket/Message.snr became nullable, an absent SNR narrowed to 0f on the way through the mapper
// and this row rendered "SNR 0.00 dB" — a measurement the radio never took.
val testNode = NodePreviewParameterProvider().minnieMouse
val message = directMessage(node = testNode, snr = null)
setContent {
MessageItem(
message = message,
node = testNode,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
ourNode = testNode,
)
}
onNodeWithText("SNR 0.00 dB", useUnmergedTree = true).assertDoesNotExist()
}
@Test
fun directMessageWithZeroSnrShowsTheReading() = runComposeUiTest {
// The other half: 0 dB is a real, strong reading and must still render.
val testNode = NodePreviewParameterProvider().minnieMouse
val message = directMessage(node = testNode, snr = 0f)
setContent {
MessageItem(
message = message,
node = testNode,
selected = false,
onClick = {},
onLongClick = {},
onStatusClick = {},
ourNode = testNode,
)
}
onNodeWithText("SNR 0.00 dB", useUnmergedTree = true).assertIsDisplayed()
}
private fun directMessage(node: Node, snr: Float?) = Message(
text = "Direct message",
time = "10:00",
fromLocal = false,
status = MessageStatus.RECEIVED,
snr = snr,
rssi = -90,
hopsAway = 0,
uuid = 1L,
receivedTime = nowMillis,
node = node,
read = false,
routingError = 0,
packetId = 1234,
emojis = listOf(),
replyId = null,
viaMqtt = false,
)
@Test
fun mqttIconIsNotDisplayedWhenViaMqttIsFalse() = runComposeUiTest {
val testNode = NodePreviewParameterProvider().minnieMouse
@@ -290,20 +290,22 @@ private fun UserAndUptimeRow(node: Node) {
@Composable
private fun SignalRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
if (node.snr != Float.MAX_VALUE) {
val snr = node.snrOrNull
if (snr != null) {
InfoItem(
label = stringResource(Res.string.snr),
value = MetricFormatter.snr(node.snr),
value = MetricFormatter.snr(snr),
icon = MeshtasticIcons.Snr,
modifier = Modifier.weight(1f),
)
} else {
Spacer(Modifier.weight(1f))
}
if (node.rssi != Int.MAX_VALUE) {
val rssi = node.rssiOrNull
if (rssi != null) {
InfoItem(
label = stringResource(Res.string.rssi),
value = MetricFormatter.rssi(node.rssi),
value = MetricFormatter.rssi(rssi),
icon = MeshtasticIcons.Rssi,
modifier = Modifier.weight(1f),
)
@@ -50,6 +50,7 @@ import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.GeoConstants
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.repository.FileService
import org.meshtastic.core.repository.MeshLogRepository
import org.meshtastic.core.repository.NodeRepository
@@ -455,8 +456,9 @@ open class MetricsViewModel(
rows = data,
epochSeconds = { (it.rxTimeOrNull() ?: 0).toLong() },
) { p ->
// An absent rssi exports as an empty field, matching the other optional metrics above.
"\"${p.rx_rssi ?: ""}\",\"${p.rx_snr}\""
// An absent rssi or snr exports as an empty field, matching the other optional metrics above. An empty
// field and "0" must stay distinguishable: 0 dB is a real reading.
"\"${p.rx_rssi ?: ""}\",\"${p.snrOrNull() ?: ""}\""
}
}
@@ -59,6 +59,7 @@ import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC
import org.meshtastic.core.model.util.formatUptime
import org.meshtastic.core.model.util.rxTimeOrNull
import org.meshtastic.core.model.util.snrOrNull
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.busy_noise_floor
import org.meshtastic.core.resources.clear
@@ -164,7 +165,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, m
val data = remember(signalData, localStatsData) { buildSignalLog(signalData, localStatsData) }
val hasNoiseFloor = remember(localStatsData) { localStatsData.any { it.local_stats?.noise_floor != 0 } }
val hasRssi = remember(signalData) { signalData.any { it.rx_rssi != null } }
val hasSnr = remember(signalData) { signalData.any { !it.rx_snr.isNaN() } }
val hasSnr = remember(signalData) { signalData.any { it.snrOrNull() != null } }
val hasAnyLocalStats = state.localStats.isNotEmpty()
val localStatsExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveLocalStatsCSV(uri, localStatsData) }
val signalExportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveSignalMetricsCSV(uri, signalData) }
@@ -319,7 +320,7 @@ private fun SignalMetricsChart(
if (noiseFloorData.size > 1) listOf(noiseFloorData.first(), noiseFloorData.last()) else emptyList()
}
val rssiData = remember(meshPackets) { meshPackets.filter { it.rx_rssi != null } }
val snrData = remember(meshPackets) { meshPackets.filter { !it.rx_snr.isNaN() } }
val snrData = remember(meshPackets) { meshPackets.filter { it.snrOrNull() != null } }
val legendData =
remember(noiseFloorData, rssiData, snrData) {
LEGEND_DATA.filter { legend ->
@@ -366,7 +367,9 @@ private fun SignalMetricsChart(
}
if (snrData.isNotEmpty()) {
/* Use a separate lineModel call to associate SNR with the right axis. */
lineModel { series(x = snrData.map { it.rxTimeOrNull() ?: 0 }, y = snrData.map { it.rx_snr }) }
lineModel {
series(x = snrData.map { it.rxTimeOrNull() ?: 0 }, y = snrData.mapNotNull { it.snrOrNull() })
}
}
}
}
@@ -583,14 +586,17 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
Row(verticalAlignment = Alignment.CenterVertically) {
MetricValueRow(color = SignalMetric.RSSI.color, text = MetricFormatter.rssi(meshPacket.rx_rssi))
Spacer(Modifier.width(12.dp))
MetricValueRow(color = SignalMetric.SNR.color, text = MetricFormatter.snr(meshPacket.rx_snr))
MetricValueRow(
color = SignalMetric.SNR.color,
text = MetricFormatter.snr(meshPacket.snrOrNull()),
)
}
}
}
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(snr = meshPacket.rx_snr)
LoraSignalIndicator(snr = meshPacket.snrOrNull())
}
}
}
@@ -33,6 +33,7 @@ import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.withContext
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.MetricFormatter
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowInstant
import org.meshtastic.core.database.entity.Packet
@@ -548,7 +549,9 @@ class DebugViewModel(
if (info.neighbors.isNotEmpty()) {
appendLine(" neighbors:")
info.neighbors.forEach {
appendLine(" - node_id: ${formatNodeWithShortName(it.node_id)} snr: ${it.snr}")
appendLine(
" - node_id: ${formatNodeWithShortName(it.node_id)} snr: ${MetricFormatter.snr(it.snr)}",
)
}
}
}