Skip to content

Commit 2d20cd8

Browse files
jamesarichclaude
andauthored
fix(ui): give rx_snr real presence semantics end to end (#6523)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4846425 commit 2d20cd8

46 files changed

Lines changed: 2194 additions & 148 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ fun DiscoveryOsmMap(
122122
position = nodeGeoPoint
123123
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
124124
title = node.longName ?: node.shortName ?: "Unknown"
125-
snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}"
125+
snippet = "SNR: ${MetricFormatter.snr(node.snr)} / RSSI: ${MetricFormatter.rssi(node.rssi)}"
126126

127127
val drawableId =
128128
if (node.isSensorNode) {

androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,10 @@ data class GetNodeDetailsResponse(
126126
val hardwareModel: String,
127127
/** Firmware version string. */
128128
val firmwareVersion: String,
129-
/** Signal-to-noise ratio of strongest signal. */
130-
val snr: Float,
131-
/** Received signal strength indicator in dB. */
132-
val rssi: Int,
129+
/** Signal-to-noise ratio in dB of the strongest signal, or null if this node has no reading. */
130+
val snr: Float?,
131+
/** Received signal strength indicator in dBm, or null if this node has no reading. */
132+
val rssi: Int?,
133133
/** Number of hops away from local node (-1 if unknown). */
134134
val hopsAway: Int,
135135
/** Channel index this node is on. */

androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ fun DiscoveryGoogleMap(
126126
MarkerComposable(
127127
state = rememberUpdatedMarkerState(position = nodeLatLng),
128128
title = node.longName ?: node.shortName ?: "Unknown",
129-
snippet = "SNR: ${node.snr} dB / RSSI: ${MetricFormatter.rssi(node.rssi)}",
129+
snippet = "SNR: ${MetricFormatter.snr(node.snr)} / RSSI: ${MetricFormatter.rssi(node.rssi)}",
130130
) {
131131
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
132132
}

core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,12 @@ object MetricFormatter {
4545

4646
fun pressure(hPa: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(hPa, decimalPlaces)} hPa"
4747

48-
fun snr(value: Float, decimalPlaces: Int = 1): String = "${NumberFormatter.format(value, decimalPlaces)} dB"
48+
/**
49+
* Formats a signal-to-noise ratio, or [UNKNOWN_VALUE] when the packet carried no measurement. 0 dB is a legitimate
50+
* reading, so it must never stand in for a missing one.
51+
*/
52+
fun snr(value: Float?, decimalPlaces: Int = 1): String =
53+
if (value == null) UNKNOWN_VALUE else "${NumberFormatter.format(value, decimalPlaces)} dB"
4954

5055
/**
5156
* Formats a received signal strength, or [UNKNOWN_VALUE] when the radio reported none. 0 dBm is a legitimate

core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ class MetricFormatterTest {
7474
@Test
7575
fun snr() {
7676
assertEquals("5.5 dB", MetricFormatter.snr(5.5f))
77+
assertEquals("-12.5 dB", MetricFormatter.snr(-12.5f))
78+
}
79+
80+
@Test
81+
fun snrAbsentIsUnknown() {
82+
assertEquals("", MetricFormatter.snr(null))
83+
}
84+
85+
@Test
86+
fun snrZeroIsARealReading() {
87+
// Must not render as unknown: 0 dB is a signal at the noise floor, not a missing measurement.
88+
assertEquals("0.0 dB", MetricFormatter.snr(0f))
7789
}
7890

7991
@Test

core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,8 +228,9 @@ class AiFunctionProviderImpl(
228228
voltage = node.deviceMetrics.voltage,
229229
hardwareModel = node.metadata?.hw_model?.name ?: "Unknown",
230230
firmwareVersion = node.metadata?.firmware_version ?: "Unknown",
231-
snr = node.snr,
232-
rssi = node.rssi,
231+
// Never surface the unset sentinels to a model — Float.MAX_VALUE reads as a superb signal.
232+
snr = node.snrOrNull,
233+
rssi = node.rssiOrNull,
233234
hopsAway = node.hopsAway,
234235
channel = node.channel,
235236
lastHeard = node.lastHeard.toLong() * MS_PER_SEC,

core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -159,10 +159,10 @@ data class NodeDetails(
159159
val hardwareModel: String,
160160
/** Firmware version string. */
161161
val firmwareVersion: String,
162-
/** Signal-to-noise ratio of the strongest received signal. */
163-
val snr: Float,
164-
/** Received signal strength indicator in dB. */
165-
val rssi: Int,
162+
/** Signal-to-noise ratio in dB of the strongest received signal, or null if this node has no reading. */
163+
val snr: Float?,
164+
/** Received signal strength indicator in dBm, or null if this node has no reading. */
165+
val rssi: Int?,
166166
/** Number of hops away from the local node (-1 if unknown). */
167167
val hopsAway: Int,
168168
/** Channel index this node is on. */

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import org.meshtastic.core.model.textMentionsNode
4444
import org.meshtastic.core.model.util.MeshDataMapper
4545
import org.meshtastic.core.model.util.decodeOrNull
4646
import org.meshtastic.core.model.util.isValidCodePoint
47+
import org.meshtastic.core.model.util.snrOrNull
4748
import org.meshtastic.core.model.util.toOneLiner
4849
import org.meshtastic.core.repository.AdminPacketHandler
4950
import org.meshtastic.core.repository.DataPair
@@ -250,7 +251,13 @@ class MeshDataHandlerImpl(
250251
// Only actionable beacons (carrying a channel offer) that we haven't already seen warrant a notification.
251252
if (beacon?.offer_channel == null) return
252253
val offer =
253-
MeshBeaconOffer(fromNodeNum = packet.from, beacon = beacon, snr = packet.rx_snr, rssi = packet.rx_rssi)
254+
MeshBeaconOffer(
255+
fromNodeNum = packet.from,
256+
beacon = beacon,
257+
// [MeshBeaconOffer.snr] is not nullable, so absent narrows to 0f. See [snrOrNull].
258+
snr = packet.snrOrNull() ?: 0f,
259+
rssi = packet.rx_rssi,
260+
)
254261
if (meshBeaconRepository.add(offer)) {
255262
radioInterfaceService.launchSessionWork(scope, session) {
256263
notificationManager.dispatch(
@@ -583,7 +590,8 @@ class MeshDataHandlerImpl(
583590
user = fromNode.user,
584591
emoji = emoji,
585592
timestamp = nowMillis,
586-
snr = packet.rx_snr,
593+
// [Reaction.snr] is not nullable, so absent narrows to 0f here. See [snrOrNull].
594+
snr = packet.snrOrNull() ?: 0f,
587595
rssi = packet.rx_rssi,
588596
hopsAway =
589597
if (packet.hop_start == 0 || packet.hop_limit > packet.hop_start) {

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import org.meshtastic.core.model.MeshLog
3838
import org.meshtastic.core.model.Node
3939
import org.meshtastic.core.model.util.isLora
4040
import org.meshtastic.core.model.util.rxTimeOrNull
41+
import org.meshtastic.core.model.util.snrOrNull
4142
import org.meshtastic.core.model.util.toOneLineString
4243
import org.meshtastic.core.model.util.toPIIString
4344
import org.meshtastic.core.repository.FromRadioPacketHandler
@@ -319,7 +320,8 @@ class MeshMessageProcessorImpl(
319320
lastHeard = packet.rxTimeOrNull()?.let(::clampTimestampToNow) ?: node.lastHeard,
320321
viaMqtt = viaMqtt,
321322
lastTransport = packet.transport_mechanism.value,
322-
snr = if (updateRadioMetrics) packet.rx_snr else node.snr,
323+
// A packet carrying no snr must not clobber the node's last real reading either.
324+
snr = if (updateRadioMetrics) packet.snrOrNull() ?: node.snr else node.snr,
323325
// A packet carrying no rssi must not clobber the node's last real reading.
324326
rssi = if (updateRadioMetrics) packet.rx_rssi ?: node.rssi else node.rssi,
325327
hopsAway = hopsAway,

core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package org.meshtastic.core.data.manager
1818

1919
import co.touchlab.kermit.Logger
2020
import org.koin.core.annotation.Single
21+
import org.meshtastic.core.common.util.MetricFormatter
2122
import org.meshtastic.core.repository.NeighborInfoHandler
2223
import org.meshtastic.core.repository.NodeManager
2324
import org.meshtastic.core.repository.NodeRepository
@@ -56,7 +57,7 @@ class NeighborInfoHandlerImpl(
5657
ni.neighbors.joinToString("\n") { n ->
5758
val user = nodeRepository.getUser(n.node_id)
5859
val name = "${user.long_name} (${user.short_name})"
59-
"$name (SNR: ${n.snr})"
60+
"$name (SNR: ${MetricFormatter.snr(n.snr)})"
6061
}
6162

6263
val fromUser = nodeRepository.getUser(from)

0 commit comments

Comments
 (0)