From 2d20cd8a4708e2ef66e98d5259f3a97ec93f240a Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:40:41 -0500 Subject: [PATCH] fix(ui): give rx_snr real presence semantics end to end (#6523) Co-authored-by: Claude Opus 5 (1M context) --- .../app/map/discovery/DiscoveryOsmMap.kt | 2 +- .../app/ai/appfunctions/AppFunctionModels.kt | 8 +- .../app/map/discovery/DiscoveryGoogleMap.kt | 2 +- .../core/common/util/MetricFormatter.kt | 7 +- .../core/common/util/MetricFormatterTest.kt | 12 + .../core/data/ai/AiFunctionProviderImpl.kt | 5 +- .../core/data/ai/AiFunctionResult.kt | 8 +- .../core/data/manager/MeshDataHandlerImpl.kt | 12 +- .../data/manager/MeshMessageProcessorImpl.kt | 4 +- .../data/manager/NeighborInfoHandlerImpl.kt | 3 +- .../52.json | 1746 +++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 3 +- .../meshtastic/core/database/entity/Packet.kt | 6 +- .../MeshtasticDatabaseMigrationTest.kt | 25 + .../usecase/settings/ExportDataUseCase.kt | 8 +- .../org/meshtastic/core/model/DataPacket.kt | 3 +- .../meshtastic/core/model/MeshBeaconOffer.kt | 18 +- .../org/meshtastic/core/model/Message.kt | 3 +- .../org/meshtastic/core/model/NeighborInfo.kt | 3 +- .../kotlin/org/meshtastic/core/model/Node.kt | 22 + .../org/meshtastic/core/model/Reaction.kt | 5 +- .../meshtastic/core/model/util/Extensions.kt | 18 +- .../core/model/util/MeshDataMapper.kt | 2 +- .../core/model/util/SnrExtensionsTest.kt | 73 + .../core/ui/component/BuildNodeDescription.kt | 8 +- .../core/ui/component/LoraSignalIndicator.kt | 69 +- .../meshtastic/core/ui/component/NodeItem.kt | 17 +- .../core/ui/component/NodeItemCompact.kt | 11 +- .../core/ui/component/SignalInfo.kt | 19 +- .../ui/component/BuildNodeDescriptionTest.kt | 21 +- .../ui/component/LoraSignalIndicatorTest.kt | 16 + .../ui/component/LoraSignalIndicatorUiTest.kt | 23 + .../feature/car/model/CarUiModels.kt | 5 + .../feature/car/screens/NodeDetailScreen.kt | 1 + .../feature/car/util/CarScreenDataBuilder.kt | 9 +- .../feature/car/util/NodeSubtitleFormatter.kt | 2 + feature/car/src/main/res/values/strings.xml | 1 + .../car/util/CarScreenDataBuilderTest.kt | 34 +- .../feature/discovery/DiscoveryScanEngine.kt | 5 +- .../export/DiscoveryReportFormatter.kt | 2 +- .../ui/component/MeshBeaconInvitationCard.kt | 2 +- .../messaging/component/MessageItemTest.kt | 62 + .../node/component/NodeDetailsSection.kt | 10 +- .../feature/node/metrics/MetricsViewModel.kt | 6 +- .../feature/node/metrics/SignalMetrics.kt | 16 +- .../settings/debugging/DebugViewModel.kt | 5 +- 46 files changed, 2194 insertions(+), 148 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt diff --git a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt index 1b7877251c..5d984f01a8 100644 --- a/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt +++ b/androidApp/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt @@ -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) { diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt index e2892d4636..de434160ae 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/ai/appfunctions/AppFunctionModels.kt @@ -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. */ diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt index 3af7ebd510..ceae1da74e 100644 --- a/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt +++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt @@ -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) } diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt index a989094ec3..f068487510 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/MetricFormatter.kt @@ -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 diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt index fe6989e5b4..e13a1a4321 100644 --- a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/MetricFormatterTest.kt @@ -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 diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt index 549a858405..a61f633755 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionProviderImpl.kt @@ -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, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt index 87619c73e3..9d8b2480f9 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/ai/AiFunctionResult.kt @@ -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. */ diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index 20f83a5c90..71d85950cf 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -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) { diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt index 305a933992..5a5ec92079 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshMessageProcessorImpl.kt @@ -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, diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt index 7d7e549a7b..ed010c7bed 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NeighborInfoHandlerImpl.kt @@ -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) diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json new file mode 100644 index 0000000000..179d0783d4 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/52.json @@ -0,0 +1,1746 @@ +{ + "formatVersion": 1, + "database": { + "version": 52, + "identityHash": "4045629645b02d263f278d1140cdec4e", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "powerChannelLabels", + "columnName": "power_channel_labels", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "signsPackets", + "columnName": "has_xeddsa_signed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translatedText", + "columnName": "translated_text", + "affinity": "TEXT" + }, + { + "fieldPath": "showTranslated", + "columnName": "show_translated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "device_link", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))", + "fields": [ + { + "fieldPath": "shortCode", + "columnName": "short_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkDescription", + "columnName": "link_description", + "affinity": "TEXT" + }, + { + "fieldPath": "isVendor", + "columnName": "is_vendor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regions", + "columnName": "regions", + "affinity": "TEXT" + }, + { + "fieldPath": "targets", + "columnName": "targets", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "short_code" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "discovery_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetsScanned", + "columnName": "presets_scanned", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homePreset", + "columnName": "home_preset", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalUniqueNodes", + "columnName": "total_unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalMessages", + "columnName": "total_messages", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalSensorPackets", + "columnName": "total_sensor_packets", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "furthestNodeDistance", + "columnName": "furthest_node_distance", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "completionStatus", + "columnName": "completion_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'complete'" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "userLatitude", + "columnName": "user_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "userLongitude", + "columnName": "user_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalDwellSeconds", + "columnName": "total_dwell_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "deviceAddress", + "columnName": "device_address", + "affinity": "TEXT" + }, + { + "fieldPath": "homeLoraConfig", + "columnName": "home_lora_config", + "affinity": "BLOB" + }, + { + "fieldPath": "homePrimaryChannel", + "columnName": "home_primary_channel", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "discovery_preset_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetName", + "columnName": "preset_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dwellDurationSeconds", + "columnName": "dwell_duration_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uniqueNodes", + "columnName": "unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directNeighborCount", + "columnName": "direct_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "meshNeighborCount", + "columnName": "mesh_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "infrastructureNodeCount", + "columnName": "infrastructure_node_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "avgAirtimeRate", + "columnName": "avg_airtime_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetSuccessRate", + "columnName": "packet_success_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetFailureRate", + "columnName": "packet_failure_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "numPacketsTx", + "columnName": "num_packets_tx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRx", + "columnName": "num_packets_rx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRxBad", + "columnName": "num_packets_rx_bad", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numRxDupe", + "columnName": "num_rx_dupe", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelay", + "columnName": "num_tx_relay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelayCanceled", + "columnName": "num_tx_relay_canceled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numOnlineNodes", + "columnName": "num_online_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTotalNodes", + "columnName": "num_total_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uptimeSeconds", + "columnName": "uptime_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovery_preset_result_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_session", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discovered_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetResultId", + "columnName": "preset_result_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "neighborType", + "columnName": "neighbor_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'direct'" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromUser", + "columnName": "distance_from_user", + "affinity": "REAL" + }, + { + "fieldPath": "hopCount", + "columnName": "hop_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isInfrastructure", + "columnName": "is_infrastructure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovered_node_preset_result_id", + "unique": false, + "columnNames": [ + "preset_result_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)" + }, + { + "name": "index_discovered_node_node_num", + "unique": false, + "columnNames": [ + "node_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_preset_result", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "preset_result_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_firmware_edition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))", + "fields": [ + { + "fieldPath": "edition", + "columnName": "edition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "welcomeMessage", + "columnName": "welcome_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventStart", + "columnName": "event_start", + "affinity": "TEXT" + }, + { + "fieldPath": "eventEnd", + "columnName": "event_end", + "affinity": "TEXT" + }, + { + "fieldPath": "timeZone", + "columnName": "time_zone", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "accentColor", + "columnName": "accent_color", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "themeJson", + "columnName": "theme_json", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareJson", + "columnName": "firmware_json", + "affinity": "TEXT" + }, + { + "fieldPath": "linksJson", + "columnName": "links_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "edition" + ] + } + }, + { + "tableName": "merge_marker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))", + "fields": [ + { + "fieldPath": "sourceDbName", + "columnName": "source_db_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mergedAt", + "columnName": "merged_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "source_db_name" + ] + } + }, + { + "tableName": "channel_set", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `channel_set` BLOB NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelSet", + "columnName": "channel_set", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4045629645b02d263f278d1140cdec4e')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 705ebcb6d0..cee617cd8a 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -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) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt index 99ab2c19d9..5ece3d4ac4 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/Packet.kt @@ -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, diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt index b5bd8d60f7..508eebe724 100644 --- a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt +++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt @@ -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 = 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 } } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt index 1759027caa..977240e027 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportDataUseCase.kt @@ -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) { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt index cc0fe001a8..b290c47db4 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -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 diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt index 0390b3c882..6d45adb7e3 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MeshBeaconOffer.kt @@ -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()) } } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt index 7b98e19377..2e5fafb5e4 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Message.kt @@ -160,7 +160,8 @@ data class Message( val routingError: Int, val packetId: Int, val emojis: List, - 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, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt index 3eac6ade30..ea806a353b 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/NeighborInfo.kt @@ -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") } } diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index 02f54367f3..9f794e1be1 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -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, ourNodeNum: Int?): Node? { diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt index 6b336b2e09..8233333429 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Reaction.kt @@ -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, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt index 586902bd82..31eb0baa2e 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/Extensions.kt @@ -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() diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt index 24833b0fa5..0298e22435 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/MeshDataMapper.kt @@ -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, diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt new file mode 100644 index 0000000000..4a34b38d81 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/SnrExtensionsTest.kt @@ -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 . + */ +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()) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt index 5d672477c7..8905be8902 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/BuildNodeDescription.kt @@ -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())) diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt index d01bbb9a48..3f55bb3b6e 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicator.kt @@ -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( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt index 5d0708cb2d..41577b3a9f 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItem.kt @@ -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), diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt index 25d7b5962b..bec0f1458d 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/NodeItemCompact.kt @@ -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( diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt index 6e41b23540..6d1c008910 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/SignalInfo.kt @@ -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, diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt index cc6f03e3e1..9e46266465 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/BuildNodeDescriptionTest.kt @@ -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") } } diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt index 1ce0b45575..acee09c70c 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorTest.kt @@ -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). diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt index 0a9317fd16..6f995dda27 100644 --- a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt +++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/LoraSignalIndicatorUiTest.kt @@ -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") } } diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt index b72d2615e2..63019ebf62 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/model/CarUiModels.kt @@ -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?) diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt index 231f78597a..7a34dd934d 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/screens/NodeDetailScreen.kt @@ -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 { diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt index 17666c85bb..a9069ce5ea 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilder.kt @@ -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 diff --git a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt index f9f026fd40..3bd06cc439 100644 --- a/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt +++ b/feature/car/src/main/kotlin/org/meshtastic/feature/car/util/NodeSubtitleFormatter.kt @@ -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 } } diff --git a/feature/car/src/main/res/values/strings.xml b/feature/car/src/main/res/values/strings.xml index 5065098c9c..b36913a965 100644 --- a/feature/car/src/main/res/values/strings.xml +++ b/feature/car/src/main/res/values/strings.xml @@ -19,6 +19,7 @@ Fair Good None + Unknown Battery Last Heard Offline diff --git a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt index e248697478..7265536f83 100644 --- a/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt +++ b/feature/car/src/test/kotlin/org/meshtastic/feature/car/util/CarScreenDataBuilderTest.kt @@ -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 diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt index 4869d2df26..505acc73da 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt @@ -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) diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt index e301850d97..1eb5d967ad 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt @@ -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) { diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt index 330c6c53ff..315709b661 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/MeshBeaconInvitationCard.kt @@ -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( diff --git a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt index 1d8e41feab..7578f2d3c0 100644 --- a/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt +++ b/feature/messaging/src/commonTest/kotlin/org/meshtastic/feature/messaging/component/MessageItemTest.kt @@ -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 diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 96506f7379..c2d1f7160d 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -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), ) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 1031f8167d..a29bce2f70 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -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() ?: ""}\"" } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt index 8a317745e3..9c1cf6d50f 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/SignalMetrics.kt @@ -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()) } } } diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index e30bf6603e..989b3620c8 100644 --- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -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)}", + ) } } }