Feat request neighbours (#3709)

Signed-off-by: Dane Evans <dane@goneepic.com>
This commit is contained in:
Dane Evans 2025-12-22 07:45:06 +11:00 committed by GitHub
parent 3e3dfe08e6
commit d33229c50f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 375 additions and 10 deletions

View file

@ -266,6 +266,13 @@ constructor(
serviceRepository.clearTracerouteResponse()
}
val neighborInfoResponse: LiveData<String?>
get() = serviceRepository.neighborInfoResponse.asLiveData()
fun clearNeighborInfoResponse() {
serviceRepository.clearNeighborInfoResponse()
}
val appIntroCompleted: StateFlow<Boolean> = uiPreferencesDataSource.appIntroCompleted
fun onAppIntroCompleted() {

View file

@ -179,6 +179,9 @@ class MeshService : Service() {
@Inject lateinit var analytics: PlatformAnalytics
private val tracerouteStartTimes = ConcurrentHashMap<Int, Long>()
private val neighborInfoStartTimes = ConcurrentHashMap<Int, Long>()
@Volatile private var lastNeighborInfo: MeshProtos.NeighborInfo? = null
private val logUuidByPacketId = ConcurrentHashMap<Int, String>()
private val logInsertJobByPacketId = ConcurrentHashMap<Int, Job>()
@ -241,6 +244,8 @@ class MeshService : Service() {
private const val DEFAULT_HISTORY_RETURN_MAX_MESSAGES = 100
private const val MAX_EARLY_PACKET_BUFFER = 128
private const val NEIGHBOR_RQ_COOLDOWN = 3 * 60 * 1000L // ms
@VisibleForTesting
internal fun buildStoreForwardHistoryRequest(
lastRequest: Int,
@ -305,6 +310,8 @@ class MeshService : Service() {
private val batteryPercentCooldownSeconds = 1500
private val batteryPercentCooldowns: HashMap<Int, Long> = HashMap()
private val oneHour = 3600
private fun getSenderName(packet: DataPacket?): String {
val name = nodeDBbyID[packet?.from]?.user?.longName
return name ?: getString(Res.string.unknown_username)
@ -995,6 +1002,138 @@ class MeshService : Service() {
}
}
Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> {
val requestId = packet.decoded.requestId
Timber.d("Processing NEIGHBORINFO_APP packet with requestId: $requestId")
val start = neighborInfoStartTimes.remove(requestId)
Timber.d("Found start time for requestId $requestId: $start")
val info =
runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull()
// Store the last neighbor info from our connected radio
if (info != null && packet.from == myInfo.myNodeNum) {
lastNeighborInfo = info
Timber.d("Stored last neighbor info from connected radio")
}
// Only show response if packet is addressed to us and we sent a request in the last 3 minutes
val isAddressedToUs = packet.to == myInfo.myNodeNum
val isRecentRequest =
start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN
if (isAddressedToUs && isRecentRequest) {
val formatted =
if (info != null) {
val fmtNode: (Int) -> String = { nodeNum ->
val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: ""
val nodeId = "!%08x".format(nodeNum)
if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId
}
buildString {
appendLine("NeighborInfo:")
appendLine("node_id: ${fmtNode(info.nodeId)}")
appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}")
appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}")
if (info.neighborsCount > 0) {
appendLine("neighbors:")
info.neighborsList.forEach { n ->
appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}")
}
}
}
} else {
// Fallback to raw string if parsing fails
String(data.payload.toByteArray())
}
val response =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val seconds = elapsedMs / 1000.0
Timber.i("Neighbor info $requestId complete in $seconds s")
"$formatted\n\nDuration: ${"%.1f".format(seconds)} s"
} else {
Timber.w("No start time found for neighbor info requestId: $requestId")
formatted
}
serviceRepository.setNeighborInfoResponse(response)
} else {
Timber.d(
"Neighbor info response filtered: ToUs=%s, isRecentRequest=%s",
isAddressedToUs,
isRecentRequest,
)
}
}
Portnums.PortNum.NEIGHBORINFO_APP_VALUE -> {
val requestId = packet.decoded.requestId
Timber.d("Processing NEIGHBORINFO_APP packet with requestId: $requestId")
val start = neighborInfoStartTimes.remove(requestId)
Timber.d("Found start time for requestId $requestId: $start")
val info =
runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull()
// Store the last neighbor info from our connected radio
if (info != null && packet.from == myInfo.myNodeNum) {
lastNeighborInfo = info
Timber.d("Stored last neighbor info from connected radio")
}
// Only show response if packet is addressed to us and we sent a request in the last 3 minutes
val isAddressedToUs = packet.to == myInfo.myNodeNum
val isRecentRequest =
start != null && (System.currentTimeMillis() - start) < NEIGHBOR_RQ_COOLDOWN
if (isAddressedToUs && isRecentRequest) {
val formatted =
if (info != null) {
val fmtNode: (Int) -> String = { nodeNum ->
val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user
val shortName = user?.shortName?.takeIf { it.isNotEmpty() } ?: ""
val nodeId = "!%08x".format(nodeNum)
if (shortName.isNotEmpty()) "$nodeId ($shortName)" else nodeId
}
buildString {
appendLine("NeighborInfo:")
appendLine("node_id: ${fmtNode(info.nodeId)}")
appendLine("last_sent_by_id: ${fmtNode(info.lastSentById)}")
appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}")
if (info.neighborsCount > 0) {
appendLine("neighbors:")
info.neighborsList.forEach { n ->
appendLine(" - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}")
}
}
}
} else {
// Fallback to raw string if parsing fails
String(data.payload.toByteArray())
}
val response =
if (start != null) {
val elapsedMs = System.currentTimeMillis() - start
val seconds = elapsedMs / 1000.0
Timber.i("Neighbor info $requestId complete in $seconds s")
"$formatted\n\nDuration: ${"%.1f".format(seconds)} s"
} else {
Timber.w("No start time found for neighbor info requestId: $requestId")
formatted
}
serviceRepository.setNeighborInfoResponse(response)
} else {
Timber.d(
"Neighbor info response filtered: isToUs=%s, isRecent=%s",
isAddressedToUs,
isRecentRequest,
)
}
}
else -> Timber.d("No custom processing needed for ${data.portnumValue} from $fromId")
}
@ -2649,6 +2788,45 @@ class MeshService : Service() {
}
}
override fun requestNeighborInfo(requestId: Int, destNum: Int) = toRemoteExceptions {
if (destNum != myNodeNum) {
neighborInfoStartTimes[requestId] = System.currentTimeMillis()
// Always send the neighbor info from our connected radio (myNodeNum), not request from destNum
val neighborInfoToSend =
lastNeighborInfo
?: run {
// If we don't have it, send dummy/interceptable data
Timber.d("No stored neighbor info from connected radio, sending dummy data")
MeshProtos.NeighborInfo.newBuilder()
.setNodeId(myNodeNum)
.setLastSentById(myNodeNum)
.setNodeBroadcastIntervalSecs(oneHour)
.addNeighbors(
MeshProtos.Neighbor.newBuilder()
.setNodeId(0) // Dummy node ID that can be intercepted
.setSnr(0f)
.setLastRxTime(currentSecond())
.setNodeBroadcastIntervalSecs(oneHour)
.build(),
)
.build()
}
// Send the neighbor info from our connected radio to the destination
packetHandler.sendToRadio(
newMeshPacketTo(destNum).buildMeshPacket(
wantAck = true,
id = requestId,
channel = nodeDBbyNodeNum[destNum]?.channel ?: 0,
) {
portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE
payload = neighborInfoToSend.toByteString()
wantResponse = true
},
)
}
}
override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions {
if (destNum != myNodeNum) {
val provideLocation = meshPrefs.shouldProvideNodeLocation(myNodeNum)

View file

@ -33,8 +33,10 @@ import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.recalculateWindowInsets
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.width
@ -47,6 +49,7 @@ import androidx.compose.material3.BadgedBox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.colorScheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Text
@ -74,6 +77,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@ -132,6 +136,7 @@ import org.meshtastic.core.strings.firmware_old
import org.meshtastic.core.strings.firmware_too_old
import org.meshtastic.core.strings.map
import org.meshtastic.core.strings.must_update
import org.meshtastic.core.strings.neighbor_info
import org.meshtastic.core.strings.nodes
import org.meshtastic.core.strings.okay
import org.meshtastic.core.strings.should_update
@ -293,6 +298,98 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode
onDismiss = { tracerouteMapError = null },
)
}
val neighborInfoResponse by uIViewModel.neighborInfoResponse.observeAsState()
neighborInfoResponse?.let { response ->
SimpleAlertDialog(
title = Res.string.neighbor_info,
text = {
Column(modifier = Modifier.fillMaxWidth()) {
fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? {
// First, try parsing directly from raw bytes of the string
var neighborInfo: MeshProtos.NeighborInfo? =
runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) }.getOrNull()
if (neighborInfo == null) {
// Next, try to decode a hex dump embedded as text (e.g., "AA BB CC ...")
val hexPairs = Regex("""\b[0-9A-Fa-f]{2}\b""").findAll(input).map { it.value }.toList()
@Suppress("detekt:MagicNumber") // byte offsets
if (hexPairs.size >= 4) {
val bytes = hexPairs.map { it.toInt(16).toByte() }.toByteArray()
neighborInfo = runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) }.getOrNull()
}
}
return neighborInfo
}
val parsed = tryParseNeighborInfo(response)
if (parsed != null) {
fun fmtNode(nodeNum: Int): String = "!%08x".format(nodeNum)
Text(text = "NeighborInfo:", style = MaterialTheme.typography.bodyMedium)
Text(
text = "node_id: ${fmtNode(parsed.nodeId)}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 8.dp),
)
Text(
text = "last_sent_by_id: ${fmtNode(parsed.lastSentById)}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 2.dp),
)
Text(
text = "node_broadcast_interval_secs: ${parsed.nodeBroadcastIntervalSecs}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 2.dp),
)
if (parsed.neighborsCount > 0) {
Text(
text = "neighbors:",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
parsed.neighborsList.forEach { n ->
Text(
text = " - node_id: ${fmtNode(n.nodeId)} snr: ${n.snr}",
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 8.dp),
)
}
}
} else {
val rawBytes = response.toByteArray()
@Suppress("detekt:MagicNumber") // byte offsets
val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' }
if (isBinary) {
val hexString = rawBytes.joinToString(" ") { "%02X".format(it) }
Text(
text = "Binary data (hex view):",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 4.dp),
)
Text(
text = hexString,
style =
MaterialTheme.typography.bodyMedium.copy(
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
),
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
)
} else {
Text(
text = response,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(),
)
}
}
}
},
dismissText = stringResource(Res.string.okay),
onDismiss = { uIViewModel.clearNeighborInfoResponse() },
)
}
val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo())
val currentDestination = navController.currentBackStackEntryAsState().value?.destination
val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination)