mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-12 21:30:02 -04:00
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:
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) {
|
||||
|
||||
+4
-4
@@ -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)
|
||||
}
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+3
-2
@@ -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. */
|
||||
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
+3
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
+2
-1
@@ -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,
|
||||
|
||||
+25
@@ -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
|
||||
}
|
||||
}
|
||||
+6
-2
@@ -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())
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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()))
|
||||
|
||||
+15
-54
@@ -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,
|
||||
|
||||
+10
-11
@@ -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")
|
||||
}
|
||||
}
|
||||
+16
@@ -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).
|
||||
|
||||
+23
@@ -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>
|
||||
|
||||
+31
-3
@@ -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
|
||||
|
||||
+3
-2
@@ -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)
|
||||
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+62
@@ -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
|
||||
|
||||
+6
-4
@@ -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),
|
||||
)
|
||||
|
||||
+4
-2
@@ -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() ?: ""}\""
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user