fix(node): stop presenting MQTT-only nodes as unheard on the current LoRa (#7138)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-12 04:51:39 -07:00
1 parent 848e124b9d
commit 6d972ea406
14 files changed
+295 -27

No files matched your search

@@ -163,9 +163,11 @@ data class NodeEntity(
/** True when this node signs its broadcasts via XEdDSA (NodeInfo.has_xeddsa_signed). */
@ColumnInfo(name = "has_xeddsa_signed", defaultValue = "0") var signsPackets: Boolean = false,
/**
* True when the radio has heard this node over RF since its current LoRa config took effect
* (NodeInfo.heard_on_current_lora). Defaults true so nodes stored before this column existed, and nodes from
* firmware that does not report it, are never shown as unheard.
* True when the radio has heard this node over RF on the LoRa configuration it is using now
* (NodeInfo.heard_on_current_lora). The radio derives this from the slot each node was heard on rather than
* clearing it on a config change, so returning to a configuration restores the previous answers. Defaults true so
* nodes stored before this column existed, and nodes from firmware that does not report it, are never shown as
* unheard.
*/
@ColumnInfo(name = "heard_on_current_lora", defaultValue = "1") var heardOnCurrentLora: Boolean = true,
/**
@@ -98,9 +98,9 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
val supportsMeshBeacon = atLeast(V2_8_0)
/**
* Whether the node reports [NodeInfo.heard_on_current_lora] - whether it has heard each node since its current LoRa
* config took effect. Gated to [UNRELEASED] until the firmware side ships. Older firmware never sends the field,
* and a proto3 bool defaults to false, so an ungated read marks every node as unheard.
* Whether the node reports [NodeInfo.heard_on_current_lora] - whether it has heard each node over RF on the LoRa
* configuration it is using now. Gated to [UNRELEASED] until the firmware side ships. Older firmware never sends
* the field, and a proto3 bool defaults to false, so an ungated read marks every node as unheard.
*
* Deliberately outside [forceEnableAll]: every other capability being wrong in a debug build shows a UI the
* firmware ignores, but this one being wrong persists false into the node DB and offers real nodes for removal.
@@ -69,7 +69,11 @@ data class Node(
val manuallyVerified: Boolean = false,
/** True when this node signs its broadcasts via XEdDSA (NodeInfo.has_xeddsa_signed). Automatic trust. */
val signsPackets: Boolean = false,
/** False when the radio has not heard this node since its current LoRa config took effect. */
/**
* False when the radio has not heard this node over RF on the LoRa configuration it is using now. The radio derives
* this rather than storing it, so returning to a configuration restores the previous answers. Ask
* [isUnheardOnCurrentLora] rather than this flag: false alone does not mean the node became unreachable.
*/
val heardOnCurrentLora: Boolean = true,
val nodeStatus: String? = null,
/** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */
@@ -84,6 +88,24 @@ data class Node(
) {
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
/**
* True when the radio does not report having heard this node over RF on the LoRa configuration in force now, and it
* is not an [viaMqtt] node.
*
* This is not a claim that the node was ever heard over RF. `heard_on_current_lora` is a single bool, and the radio
* does not expose its "heard over RF at least once" bit separately, so a node heard under different settings and a
* node never heard at all (one added as a shared contact, say) are indistinguishable here. Both read true. Callers
* that must not act on the second case need another signal - the removal offer relies on favourites, which is what
* firmware marks a contact.
*
* [viaMqtt] nodes are excluded because they arrive over the internet rather than over our own radio, so the radio
* never marks them heard over RF and [heardOnCurrentLora] is false for them permanently - not because a setting
* changed. Presenting those as unreachable would badge every node on an MQTT-uplinked mesh and offer it for
* removal, and removing one is pointless because it returns on the next uplinked packet.
*/
val isUnheardOnCurrentLora: Boolean
get() = !heardOnCurrentLora && !viaMqtt
val isOnline: Boolean
get() = isOnline(onlineTimeThreshold())
@@ -164,4 +164,19 @@ class NodeTest {
private fun nodeWithPosition(num: Int, latitudeI: Int, longitudeI: Int): Node =
Node(num = num, position = Position(latitude_i = latitudeI, longitude_i = longitudeI))
@Test
fun `isUnheardOnCurrentLora is true for a node not heard on the config in force now`() {
assertTrue(Node(num = 1, heardOnCurrentLora = false).isUnheardOnCurrentLora)
assertFalse(Node(num = 1, heardOnCurrentLora = true).isUnheardOnCurrentLora)
}
@Test
fun `isUnheardOnCurrentLora excludes MQTT nodes the radio never heard over RF`() {
// The radio sets the mark only on an RF hear, so a node that only ever arrives over MQTT reports false for
// the lifetime of the entry. Presenting that as "unreachable since you changed settings" would badge every
// node on an MQTT-uplinked mesh and offer it for removal, and removing one is pointless.
assertFalse(Node(num = 1, heardOnCurrentLora = false, viaMqtt = true).isUnheardOnCurrentLora)
assertFalse(Node(num = 1, heardOnCurrentLora = true, viaMqtt = true).isUnheardOnCurrentLora)
}
}
@@ -1354,7 +1354,7 @@
<string name="node_list_help_node_details">Node Details</string>
<string name="node_list_help_title">Node List Help</string>
<string name="node_list_long_click_label">Node options</string>
<string name="node_not_heard_on_current_lora">Not heard since you changed settings</string>
<string name="node_not_heard_on_current_lora">Not heard on your current LoRa settings</string>
<string name="node_number">Node Number</string>
<string name="node_request_send_failed">Couldn't send request. Try again.</string>
<string name="node_restarting">Restarting…</string>
@@ -1377,8 +1377,8 @@
<string name="nodes_empty_searching_hint">Nearby nodes will appear here as they're discovered.</string>
<string name="nodes_empty_searching_title">Searching for nodes</string>
<string name="nodes_queued_for_deletion">%1$d nodes queued for deletion:</string>
<string name="nodes_unheard_banner">%1$d nodes not heard since you changed settings</string>
<string name="nodes_unheard_banner_one">1 node not heard since you changed settings</string>
<string name="nodes_unheard_banner">%1$d nodes not heard on your current LoRa settings</string>
<string name="nodes_unheard_banner_one">1 node not heard on your current LoRa settings</string>
<string name="nodes_unheard_keep">Keep</string>
<string name="nodes_unheard_remove">Remove</string>
<string name="noise_floor">Noise Floor</string>
@@ -461,9 +461,10 @@ private fun MetricsGrid(items: List<@Composable () -> Unit>) {
* [LastHeardInfo] tinted StatusGreen when [online] — the "online" affordance — rendered plain otherwise. Shared by the
* complete and compact node rows.
*
* When [heardOnCurrentLora] is false the radio has not heard this node since its LoRa settings changed, so it cannot be
* reached from here. That is a different claim from "offline" and takes precedence: an online node can still be
* unreachable, and the online tint would say the opposite.
* When [heardOnCurrentLora] is false the radio has not heard this node on the LoRa configuration it is using now, so it
* cannot be reached from here. That is a different claim from "offline" and takes precedence: an online node can still
* be unreachable, and the online tint would say the opposite. Callers pass Node.isUnheardOnCurrentLora inverted, not
* the raw flag, so MQTT-only nodes are not presented as unreachable.
*/
@Composable
internal fun StatusAwareLastHeard(
@@ -560,7 +561,7 @@ private fun NodeItemHeader(
lastHeard = thatNode.lastHeard,
online = !isThisNode && thatNode.isOnline,
contentColor = contentColor,
heardOnCurrentLora = isThisNode || thatNode.heardOnCurrentLora,
heardOnCurrentLora = isThisNode || !thatNode.isUnheardOnCurrentLora,
)
}
}
@@ -329,7 +329,7 @@ private fun CompactHealthRow(
online = thatNode.isOnline,
contentColor = contentColor,
relative = lastHeardIsRelative,
heardOnCurrentLora = isThisNode || thatNode.heardOnCurrentLora,
heardOnCurrentLora = isThisNode || !thatNode.isUnheardOnCurrentLora,
)
},
)
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.component
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.v2.runComposeUiTest
import org.meshtastic.core.common.util.MeasurementSystem
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.Node
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.getString
import org.meshtastic.core.resources.node_not_heard_on_current_lora
import org.meshtastic.proto.User
import kotlin.test.Test
/**
* The unreachable marker on both node rows. Each feeds [StatusAwareLastHeard] from `Node.isUnheardOnCurrentLora`, not
* the raw `heardOnCurrentLora` flag, so an MQTT-only node must not wear it: the radio sets the mark only on an RF hear,
* so such a node reports false for the life of the entry and would otherwise be badged forever and offered for removal.
*
* Driven through the real rows rather than [StatusAwareLastHeard] directly - the wiring is what can regress, so the
* via_mqtt cases fail if either row goes back to reading the raw flag.
*/
@OptIn(ExperimentalTestApi::class)
class NodeItemUnheardMarkerTest {
private fun node(viaMqtt: Boolean) = Node(
num = 1928,
user = User(long_name = "Minnie Mouse", short_name = "MiMo", id = "!minnie"),
lastHeard = (nowSeconds - 300).toInt(),
heardOnCurrentLora = false,
viaMqtt = viaMqtt,
)
private fun ComposeUiTest.markerCount() =
onAllNodesWithContentDescription(getString(Res.string.node_not_heard_on_current_lora))
private fun ComposeUiTest.setNodeItem(viaMqtt: Boolean) = setContent {
MaterialTheme {
NodeItem(
thisNode = null,
thatNode = node(viaMqtt),
distanceUnits = MeasurementSystem.METRIC,
tempInFahrenheit = false,
connectionState = ConnectionState.Connected,
)
}
}
private fun ComposeUiTest.setNodeItemCompact(viaMqtt: Boolean) = setContent {
MaterialTheme {
NodeItemCompact(thisNode = null, thatNode = node(viaMqtt), distanceUnits = MeasurementSystem.METRIC)
}
}
@Test
fun nodeItem_marksANodeNotHeardOnTheConfigInForceNow() = runComposeUiTest {
setNodeItem(viaMqtt = false)
markerCount().assertCountEquals(1)
}
@Test
fun nodeItem_withholdsTheMarkerFromAnMqttNode() = runComposeUiTest {
setNodeItem(viaMqtt = true)
markerCount().assertCountEquals(0)
}
@Test
fun nodeItemCompact_marksANodeNotHeardOnTheConfigInForceNow() = runComposeUiTest {
setNodeItemCompact(viaMqtt = false)
markerCount().assertCountEquals(1)
}
@Test
fun nodeItemCompact_withholdsTheMarkerFromAnMqttNode() = runComposeUiTest {
setNodeItemCompact(viaMqtt = true)
markerCount().assertCountEquals(0)
}
}
@@ -84,8 +84,8 @@ fun NodeItemCompleteActivePreview() {
@PreviewLightDark
@Composable
fun NodeItemUnheardPreview() {
// Heard recently enough to count as online, but not since the radio's LoRa config changed. The unheard marker
// has to win over the online tint here: green would claim it is reachable.
// Heard recently enough to count as online, but not on the LoRa config the radio is using now. The unheard
// marker has to win over the online tint here: green would claim it is reachable.
val unheardNode =
previewNodes.minnieMouse.copy(
lastHeard = (org.meshtastic.core.common.util.nowSeconds - 300).toInt(),
@@ -66,7 +66,7 @@ open class GetFilteredNodesUseCase constructor(private val nodeRepository: NodeR
.filter { node -> if (filter.excludeMqtt) !node.viaMqtt else true }
// The connected node is never unreachable from itself, and both row renderers already exempt it.
.filter { node ->
if (filter.excludeUnheard) node.heardOnCurrentLora || node.num == ourNum else true
if (filter.excludeUnheard) !node.isUnheardOnCurrentLora || node.num == ourNum else true
}
}
}
@@ -144,15 +144,7 @@ fun NodeListScreen(
// radio is not something the node list may delete. ourNode and unfilteredNodes come from independent flows, so
// the list can already contain the local node while ourNode is still null. Offer nothing until it is known,
// rather than risk removing the user's own node from the radio.
val unheardNodes =
remember(unfilteredNodes, ourNode) {
val ourNum = ourNode?.num
if (ourNum == null) {
emptyList()
} else {
unfilteredNodes.filter { !it.heardOnCurrentLora && !it.isFavorite && it.num != ourNum }
}
}
val unheardNodes = remember(unfilteredNodes, ourNode) { selectRemovableUnheardNodes(unfilteredNodes, ourNode?.num) }
val deviceImageUrls by viewModel.deviceImageUrls.collectAsStateWithLifecycle()
val ignoredNodeCount = unfilteredNodes.count { it.isIgnored }
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import org.meshtastic.core.model.Node
/**
* The nodes the unheard banner counts and its one-tap clear would remove.
*
* Deliberately narrower than [Node.isUnheardOnCurrentLora] alone, because this one deletes:
* - **Favourites are exempt.** An explicit keep, and it is also what firmware marks a node added as a shared contact,
* which has never been heard over RF and would otherwise always qualify.
* - **The connected radio is exempt**, and nothing is offered at all until [ourNum] is known. It and the node list
* arrive on independent flows, so the list can hold the local node while [ourNum] is still null; offering then would
* risk removing the user's own node.
* - **MQTT nodes are exempt** via [Node.isUnheardOnCurrentLora]: they read unheard permanently, and removing one
* achieves nothing because the next uplinked packet brings it back.
*/
internal fun selectRemovableUnheardNodes(nodes: List<Node>, ourNum: Int?): List<Node> {
if (ourNum == null) return emptyList()
return nodes.filter { it.isUnheardOnCurrentLora && !it.isFavorite && it.num != ourNum }
}
@@ -58,6 +58,7 @@ class GetFilteredNodesUseCaseTest {
viaMqtt: Boolean = false,
signsPackets: Boolean = false,
publicKey: ByteString? = null,
heardOnCurrentLora: Boolean = true,
): Node {
val user = User(id = "!$num", long_name = name, short_name = "N$num", role = role)
return Node(
@@ -67,6 +68,7 @@ class GetFilteredNodesUseCaseTest {
viaMqtt = viaMqtt,
signsPackets = signsPackets,
publicKey = publicKey,
heardOnCurrentLora = heardOnCurrentLora,
)
}
@@ -193,4 +195,27 @@ class GetFilteredNodesUseCaseTest {
assertEquals(listOf(2), result.map { it.num })
}
@Test
fun `the unheard filter drops a node the radio can no longer reach`() = runTest {
val nodes = listOf(createNode(1), createNode(2, heardOnCurrentLora = false))
every { nodeRepository.getNodes() } returns flowOf(nodes)
val result = useCase(NodeFilterState(excludeUnheard = true), NodeSortOption.LAST_HEARD).first()
assertEquals(listOf(1), result.map { it.num })
}
@Test
fun `the unheard filter keeps an MQTT node which reads unheard permanently`() = runTest {
// The radio only marks a node heard on the current LoRa config when it arrives over RF, so an MQTT-only node
// reads false permanently. That is not the same as "went unreachable when the settings changed", and hiding it
// would empty the list on an MQTT-uplinked mesh.
val nodes = listOf(createNode(1, viaMqtt = true, heardOnCurrentLora = false), createNode(2))
every { nodeRepository.getNodes() } returns flowOf(nodes)
val result = useCase(NodeFilterState(excludeUnheard = true), NodeSortOption.LAST_HEARD).first()
assertEquals(listOf(1, 2), result.map { it.num }.sorted())
}
}
@@ -0,0 +1,77 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.node.list
import org.meshtastic.core.model.Node
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* What the unheard banner counts and its one-tap clear would delete. This is the highest-consequence consumer of
* [Node.isUnheardOnCurrentLora] - it removes nodes - so each exemption is pinned separately.
*/
class RemovableUnheardNodesTest {
private val ourNum = 1
private fun node(
num: Int,
heardOnCurrentLora: Boolean = false,
viaMqtt: Boolean = false,
isFavorite: Boolean = false,
) = Node(num = num, heardOnCurrentLora = heardOnCurrentLora, viaMqtt = viaMqtt, isFavorite = isFavorite)
@Test
fun offersANodeNotHeardOnTheConfigInForceNow() {
val nodes = listOf(node(2), node(3, heardOnCurrentLora = true))
assertEquals(listOf(2), selectRemovableUnheardNodes(nodes, ourNum).map { it.num })
}
@Test
fun neverOffersAnMqttNode() {
// Reads unheard for the life of the entry, and deleting it achieves nothing - the next uplinked packet
// brings it straight back.
val nodes = listOf(node(2, viaMqtt = true), node(3))
assertEquals(listOf(3), selectRemovableUnheardNodes(nodes, ourNum).map { it.num })
}
@Test
fun neverOffersAFavourite() {
// Also covers shared contacts: firmware marks a contact favourite, and one has never been heard over RF.
val nodes = listOf(node(2, isFavorite = true), node(3))
assertEquals(listOf(3), selectRemovableUnheardNodes(nodes, ourNum).map { it.num })
}
@Test
fun neverOffersTheConnectedRadio() {
val nodes = listOf(node(ourNum), node(3))
assertEquals(listOf(3), selectRemovableUnheardNodes(nodes, ourNum).map { it.num })
}
@Test
fun offersNothingUntilTheLocalNodeNumberIsKnown() {
// ourNode and the node list arrive on independent flows; offering here could delete the user's own node.
val nodes = listOf(node(2), node(3))
assertTrue(selectRemovableUnheardNodes(nodes, ourNum = null).isEmpty())
}
}