Skip to content

Commit a8e61e0

Browse files
jamesarichclaude
andcommitted
fix(messaging): carry absent snr through to the UI instead of 0 dB
The snrOrNull() seam stopped at the module boundary: MeshDataMapper narrowed an absent reading to 0f because DataPacket.snr was not nullable, so MessageItem rendered "SNR 0.00 dB" for a direct packet that carried no measurement -- a reading the radio never took. Message.displayTime hides the equivalent problem for time by falling back to received_time, but snr had no such guard. Make the chain nullable end to end, following the template schema 51 already set for rssi: - DataPacket.snr, Message.snr, Reaction.snr, MeshBeaconOffer.snr -> Float? - Room packet.snr and reactions.snr -> nullable, via AutoMigration(51 -> 52) - MeshDataMapper passes snrOrNull() straight through, no fallback As with the rssi migration, rows written before schema 52 keep their stored 0, so a legacy 0 dB reading stays indistinguishable from "no reading" for existing history; only new rows carry true presence. The column comments say so. MeshBeaconInvitationCard's `offer.snr != 0f` check becomes a null check, which also fixes its OR-gate showing "0 dB" as a real reading whenever rssi happened to be present. Left out deliberately: discovered_node.snr. Its readers aggregate -- DiscoveryRankingEngine takes a median over `nodes.map { it.snr }` and DiscoveryMapViewModel dedups with `maxByOrNull { it.snr }` -- so nullability there is a semantics decision (is an unmeasured node excluded from the median, or sorted last?), not a mechanical change. It stays NOT NULL until that is decided. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 166cdb7 commit a8e61e0

11 files changed

Lines changed: 1861 additions & 16 deletions

File tree

core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json

Lines changed: 1746 additions & 0 deletions
Large diffs are not rendered by default.

core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
130130
AutoMigration(from = 48, to = 49),
131131
AutoMigration(from = 49, to = 50),
132132
AutoMigration(from = 50, to = 51),
133+
AutoMigration(from = 51, to = 52),
133134
],
134-
version = 51,
135+
version = 52,
135136
exportSchema = true,
136137
)
137138
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)

core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ data class Packet(
102102
@ColumnInfo(name = "data") val data: DataPacket,
103103
@ColumnInfo(name = "packet_id", defaultValue = "0") val packetId: Int = 0,
104104
@ColumnInfo(name = "routing_error", defaultValue = "-1") var routingError: Int = -1,
105-
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
105+
/** Null when the packet carried no snr. Rows written before schema 52 store 0 for both absent and 0 dB. */
106+
@ColumnInfo(name = "snr") val snr: Float? = null,
106107
/** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
107108
@ColumnInfo(name = "rssi") val rssi: Int? = null,
108109
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,
@@ -162,7 +163,8 @@ data class ReactionEntity(
162163
@ColumnInfo(name = "user_id") val userId: String,
163164
val emoji: String,
164165
val timestamp: Long,
165-
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
166+
/** Null when the packet carried no snr. Rows written before schema 52 store 0 for both absent and 0 dB. */
167+
@ColumnInfo(name = "snr") val snr: Float? = null,
166168
/** Null when the radio reported no rssi. Rows written before schema 51 store 0 for both absent and 0 dBm. */
167169
@ColumnInfo(name = "rssi") val rssi: Int? = null,
168170
@ColumnInfo(name = "hopsAway", defaultValue = "-1") val hopsAway: Int = -1,

core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,29 @@ class MeshtasticDatabaseMigrationTest {
108108
}
109109
}
110110

111+
@Test
112+
fun snrColumnsGoNullableWithoutLosingRows() = runTest {
113+
helper.createDatabase(SNR_NULLABLE_FROM_VERSION).use { connection ->
114+
connection.execSQL(
115+
"INSERT INTO packet (uuid, myNodeNum, port_num, contact_key, received_time, read, data, snr, rssi) " +
116+
"VALUES (1, 42, 1, '0^all', 1000, 1, '{}', 0.0, -70)",
117+
)
118+
connection.execSQL(
119+
"INSERT INTO reactions (myNodeNum, reply_id, user_id, emoji, timestamp, snr, rssi) " +
120+
"VALUES (42, 7, '!abc', 'X', 2000, -12.5, -70)",
121+
)
122+
}
123+
124+
helper.runMigrationsAndValidate(SNR_NULLABLE_TO_VERSION, emptyList()).use { connection ->
125+
// A stored 0 dB must survive the recreate as 0, not become NULL: it is a real reading.
126+
assertEquals(listOf("0.0"), queryColumn(connection, "SELECT snr FROM packet"))
127+
assertEquals(listOf("-12.5"), queryColumn(connection, "SELECT snr FROM reactions"))
128+
// A NULL is now storable where the column was previously NOT NULL DEFAULT 0.
129+
connection.execSQL("UPDATE packet SET snr = NULL WHERE uuid = 1")
130+
assertEquals(listOf(null), queryColumn(connection, "SELECT snr FROM packet"))
131+
}
132+
}
133+
111134
/** Reads one column of every row as a string, with SQL NULL surfaced as Kotlin null. */
112135
private fun queryColumn(connection: SQLiteConnection, sql: String): List<String?> =
113136
connection.prepare(sql).use { statement ->
@@ -130,5 +153,7 @@ class MeshtasticDatabaseMigrationTest {
130153
const val EARLIEST_SCHEMA_VERSION = 3
131154
const val RSSI_NULLABLE_FROM_VERSION = 50
132155
const val RSSI_NULLABLE_TO_VERSION = 51
156+
const val SNR_NULLABLE_FROM_VERSION = 51
157+
const val SNR_NULLABLE_TO_VERSION = 52
133158
}
134159
}

core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ data class DataPacket(
5151
var channel: Int = 0, // channel index
5252
var wantAck: Boolean = true, // If true, the receiver should send an ack back
5353
var hopStart: Int = 0,
54-
var snr: Float = 0f,
54+
/** Signal-to-noise ratio in dB, or null when the packet carried no measurement. 0 dB is a valid reading. */
55+
var snr: Float? = null,
5556
/** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
5657
var rssi: Int? = null,
5758
var replyId: Int? = null, // If this is a reply to a previous message, this is the ID of that message

core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@ import org.meshtastic.proto.MeshBeacon
2727
*
2828
* @param fromNodeNum The node that broadcast the beacon (informational only — beacons are unsigned).
2929
* @param beacon The decoded advertisement, carrying the display [message][MeshBeacon.message] and the join offer.
30-
* @param snr Signal-to-noise ratio of the received beacon packet, in dB (0 when unknown).
30+
* @param snr Signal-to-noise ratio of the received beacon packet, in dB, or null when the radio reported none.
3131
* @param rssi Received signal strength of the beacon packet, in dBm, or null when the radio reported none.
3232
*/
33-
data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr: Float = 0f, val rssi: Int? = null) {
33+
data class MeshBeaconOffer(
34+
val fromNodeNum: Int,
35+
val beacon: MeshBeacon,
36+
val snr: Float? = null,
37+
val rssi: Int? = null,
38+
) {
3439
/** Stable identity for dedup/dismiss: a given sender advertising a given channel is one standing invitation. */
3540
val key: String
3641
get() = "$fromNodeNum:${beacon.offer_channel?.name.orEmpty()}"
@@ -55,9 +60,10 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
5560

5661
/**
5762
* Inverse of [encode]; returns null for a structurally malformed record (wrong field count, unparseable node
58-
* number, or an undecodable beacon payload). An unparseable snr falls back to 0 and an unparseable rssi to
59-
* absent — they are non-critical display metrics, not identity, so a bad numeric there does not discard an
60-
* otherwise-valid invitation. An absent rssi encodes as `null`, which [String.toIntOrNull] round-trips back.
63+
* number, or an undecodable beacon payload). An unparseable snr or rssi falls back to absent — they are
64+
* non-critical display metrics, not identity, so a bad numeric there does not discard an otherwise-valid
65+
* invitation. An absent value encodes as `null`, which [String.toFloatOrNull]/[String.toIntOrNull] round-trip
66+
* back to null.
6167
*/
6268
@Suppress("ReturnCount")
6369
fun decode(record: String): MeshBeaconOffer? {
@@ -66,7 +72,7 @@ data class MeshBeaconOffer(val fromNodeNum: Int, val beacon: MeshBeacon, val snr
6672
val node = parts[0].toIntOrNull() ?: return null
6773
val beaconBytes = parts.last().decodeBase64()?.toByteArray() ?: return null
6874
val beacon = runCatching { MeshBeacon.ADAPTER.decode(beaconBytes) }.getOrNull() ?: return null
69-
return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull() ?: 0f, parts[2].toIntOrNull())
75+
return MeshBeaconOffer(node, beacon, parts[1].toFloatOrNull(), parts[2].toIntOrNull())
7076
}
7177
}
7278
}

core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ data class Message(
160160
val routingError: Int,
161161
val packetId: Int,
162162
val emojis: List<Reaction>,
163-
val snr: Float,
163+
/** Signal-to-noise ratio in dB, or null when the packet carried no measurement. 0 dB is a valid reading. */
164+
val snr: Float?,
164165
/** Received signal strength, or null when the radio did not report one. 0 dBm is a valid reading. */
165166
val rssi: Int?,
166167
val hopsAway: Int,

core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ data class Reaction(
2424
val user: User,
2525
val emoji: String,
2626
val timestamp: Long,
27-
val snr: Float,
27+
/**
28+
* Signal-to-noise ratio in dB, or null when the packet carried no measurement (locally sent reactions included).
29+
*/
30+
val snr: Float?,
2831
/** Received signal strength, or null when the radio did not report one (locally sent reactions included). */
2932
val rssi: Int?,
3033
val hopsAway: Int,

core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,7 @@ open class MeshDataMapper(private val nodeIdLookup: NodeIdLookup) {
4444
channel = if (packet.pki_encrypted == true) NodeAddress.PKC_CHANNEL_INDEX else packet.channel,
4545
wantAck = packet.want_ack == true,
4646
hopStart = packet.hop_start,
47-
// Narrows absent to 0f because [DataPacket.snr] is not nullable; a genuine 0 dB reading and "no reading"
48-
// become indistinguishable past this point. See [snrOrNull].
49-
snr = packet.snrOrNull() ?: 0f,
47+
snr = packet.snrOrNull(),
5048
rssi = packet.rx_rssi,
5149
replyId = decoded.reply_id,
5250
relayNode = packet.relay_node,

feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ internal fun MeshBeaconInvitationCard(
9999
color = MaterialTheme.colorScheme.onSurfaceVariant,
100100
)
101101
}
102-
if (offer.rssi != null || offer.snr != 0f) {
102+
if (offer.rssi != null || offer.snr != null) {
103103
Text(
104104
text =
105105
stringResource(

0 commit comments

Comments
 (0)