From 31705f316bf809f4252e59d16d0156eeab8fd2d6 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Mon, 27 Oct 2025 17:28:29 +1100 Subject: [PATCH 01/10] initial pass to add a request neighbour info button. --- .../java/com/geeksville/mesh/service/MeshService.kt | 12 ++++++++++++ .../org/meshtastic/core/service/IMeshService.aidl | 3 +++ core/strings/src/main/res/values/strings.xml | 1 + .../meshtastic/feature/node/component/NodeMenu.kt | 2 ++ .../feature/node/component/RemoteDeviceActions.kt | 7 +++++++ .../feature/node/detail/NodeDetailViewModel.kt | 10 ++++++++++ 6 files changed, 35 insertions(+) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 81d64d0d75..4872b93343 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -2203,6 +2203,18 @@ class MeshService : Service() { } } + override fun requestNeighbourInfo(destNum: Int) = toRemoteExceptions { + if (destNum != myNodeNum) { + packetHandler.sendToRadio( + newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) { + portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE // fixme - hardcode for now. + wantResponse = true + payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() + }, + ) + } + } + override fun requestPosition(destNum: Int, position: Position) = toRemoteExceptions { if (destNum != myNodeNum) { // Determine the best position to send based on user preferences and available diff --git a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 13ab9cef78..1a8e83e97e 100644 --- a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -170,4 +170,7 @@ interface IMeshService { /// Send request for node UserInfo void requestUserInfo(in int destNum); + + /// Send request for node Neighbours + void requestNeighbourInfo(in int destNum); } diff --git a/core/strings/src/main/res/values/strings.xml b/core/strings/src/main/res/values/strings.xml index 2dd634898a..47f42df6bd 100644 --- a/core/strings/src/main/res/values/strings.xml +++ b/core/strings/src/main/res/values/strings.xml @@ -719,6 +719,7 @@ Warning: This contact is known, importing will overwrite the previous contact information. Public Key Changed Import + Request NeighborInfo Request Metadata Actions Firmware diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt index ce8a578ed8..334f9e19ff 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeMenu.kt @@ -88,6 +88,8 @@ sealed class NodeMenuAction { data class RequestUserInfo(val node: Node) : NodeMenuAction() + data class RequestNeighbourInfo(val node: Node) : NodeMenuAction() + data class RequestPosition(val node: Node) : NodeMenuAction() data class TraceRoute(val node: Node) : NodeMenuAction() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt index ed40dd88ef..b3a1c7c1cf 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -20,6 +20,7 @@ package org.meshtastic.feature.node.component import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.twotone.Message import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.twotone.Mediation import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import org.meshtastic.core.database.model.Node @@ -55,4 +56,10 @@ internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction lastTracerouteTime = lastTracerouteTime, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) }, ) + ListItem( + text = stringResource(id = R.string.request_neighbor_info), + leadingIcon = Icons.TwoTone.Mediation, + trailingIcon = null, + onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighbourInfo(node))) }, + ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 6a4b5fadac..5731d5ae72 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -54,6 +54,7 @@ constructor( is NodeMenuAction.Ignore -> ignoreNode(action.node) is NodeMenuAction.Favorite -> favoriteNode(action.node) is NodeMenuAction.RequestUserInfo -> requestUserInfo(action.node.num) + is NodeMenuAction.RequestNeighbourInfo -> requestNeighbourInfo(action.node.num) is NodeMenuAction.RequestPosition -> requestPosition(action.node.num) is NodeMenuAction.TraceRoute -> { requestTraceroute(action.node.num) @@ -110,6 +111,15 @@ constructor( } } + private fun requestNeighbourInfo(destNum: Int) { + Timber.i("Requesting NeighbourInfo for '$destNum'") + try { + serviceRepository.meshService?.requestNeighbourInfo(destNum) + } catch (ex: RemoteException) { + Timber.e("Request NeighbourInfo error: ${ex.message}") + } + } + private fun requestPosition(destNum: Int, position: Position = Position(0.0, 0.0, 0)) { Timber.i("Requesting position for '$destNum'") try { From 8ac051eb86c3177f43f28bd90996bd4125d9fd26 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Mon, 27 Oct 2025 23:41:54 +1100 Subject: [PATCH 02/10] mostly working? Need to figure out decoding - and I have differences between the debug panel and the display, and neither make sense --- .../java/com/geeksville/mesh/model/UIState.kt | 7 + .../geeksville/mesh/service/MeshService.kt | 31 +++- .../main/java/com/geeksville/mesh/ui/Main.kt | 133 ++++++++++++++++++ .../meshtastic/core/service/IMeshService.aidl | 6 +- .../core/service/ServiceRepository.kt | 12 ++ .../node/detail/NodeDetailViewModel.kt | 3 +- 6 files changed, 184 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/model/UIState.kt b/app/src/main/java/com/geeksville/mesh/model/UIState.kt index 4a0ae5954b..f54eef8193 100644 --- a/app/src/main/java/com/geeksville/mesh/model/UIState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/UIState.kt @@ -263,6 +263,13 @@ constructor( serviceRepository.clearTracerouteResponse() } + val neighborInfoResponse: LiveData + get() = serviceRepository.neighborInfoResponse.asLiveData() + + fun clearNeighborInfoResponse() { + serviceRepository.clearNeighborInfoResponse() + } + val appIntroCompleted: StateFlow = uiPreferencesDataSource.appIntroCompleted fun onAppIntroCompleted() { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 4872b93343..ce933ddc75 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -156,6 +156,7 @@ class MeshService : Service() { @Inject lateinit var analytics: PlatformAnalytics private val tracerouteStartTimes = ConcurrentHashMap() + private val neighborInfoStartTimes = ConcurrentHashMap() companion object { @@ -839,6 +840,24 @@ 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 response = if (start != null) { + val elapsedMs = System.currentTimeMillis() - start + val seconds = elapsedMs / 1000.0 + Timber.i("Neighbor info $requestId complete in $seconds s") + val neighborData = String(data.payload.toByteArray()) + "$neighborData\n\nDuration: ${"%.1f".format(seconds)} s" + } else { + Timber.w("No start time found for neighbor info requestId: $requestId") + String(data.payload.toByteArray()) + } + serviceRepository.setNeighborInfoResponse(response) + } + else -> Timber.d("No custom processing needed for ${data.portnumValue}") } @@ -2203,13 +2222,17 @@ class MeshService : Service() { } } - override fun requestNeighbourInfo(destNum: Int) = toRemoteExceptions { + override fun requestNeighbourInfo(requestId: Int, destNum: Int) = toRemoteExceptions { if (destNum != myNodeNum) { + neighborInfoStartTimes[requestId] = System.currentTimeMillis() packetHandler.sendToRadio( - newMeshPacketTo(destNum).buildMeshPacket(channel = nodeDBbyNodeNum[destNum]?.channel ?: 0) { - portnumValue = Portnums.PortNum.NODEINFO_APP_VALUE // fixme - hardcode for now. + newMeshPacketTo(destNum).buildMeshPacket( + wantAck = true, + id = requestId, + channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, + ) { + portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE wantResponse = true - payload = nodeDBbyNodeNum[myNodeNum]!!.user.toByteString() }, ) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 49c5c733f4..2bdceef483 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -32,7 +32,10 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer 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 @@ -46,6 +49,8 @@ import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold @@ -76,6 +81,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +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 import androidx.navigation.NavDestination @@ -229,6 +236,132 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode onDismiss = { uIViewModel.clearTracerouteResponse() }, ) } + + val neighborInfoResponse by uIViewModel.neighborInfoResponse.observeAsState() + neighborInfoResponse?.let { response -> + SimpleAlertDialog( + title = R.string.neighbor_info, + text = { + // Render the neighbor info response plainly + // Render the neighbor info response as a hex dump, interpreting any binary data as hex bytes. + // If the data appears to be binary, try to render its hex representation. + Column(modifier = Modifier.fillMaxWidth()) { + val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' } + if (isBinary) { + // Render as hex string (show hex dump) + val hexString = response.toByteArray() + .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() + ) + } + + // The previous method tries to "find hex bytes" in the formatted response string, + // but since in 'isBinary' case we're showing our *own* hexString, let's directly use the bytes we made above for that. + // Use the *actual* binary: derive the nodeId from the first 4 bytes of the response, not the hex string. + + val rawBytes = response.toByteArray() + // For every 4-byte chunk, interpret as nodeId and output it. + // Interpret the byte array using the NeighborInfo protobuf layout. + // Expected: node_id (4 bytes), last_sent_by_id (4 bytes), node_broadcast_interval_secs (4 bytes), repeated Neighbor (8 bytes each: 4 for id, 4 for snr float) + if (rawBytes.size >= 12) { + val nodeId = + (rawBytes[0].toUByte().toLong() or + (rawBytes[1].toUByte().toLong() shl 8) or + (rawBytes[2].toUByte().toLong() shl 16) or + (rawBytes[3].toUByte().toLong() shl 24)) + val lastSentById = + (rawBytes[4].toUByte().toLong() or + (rawBytes[5].toUByte().toLong() shl 8) or + (rawBytes[6].toUByte().toLong() shl 16) or + (rawBytes[7].toUByte().toLong() shl 24)) + val nodeBroadcastIntervalSecs = + (rawBytes[8].toUByte().toLong() or + (rawBytes[9].toUByte().toLong() shl 8) or + (rawBytes[10].toUByte().toLong() shl 16) or + (rawBytes[11].toUByte().toLong() shl 24)) + Text( + text = "node_id: 0x${nodeId.toString(16)} = $nodeId", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = "last_sent_by_id: 0x${lastSentById.toString(16)} = $lastSentById", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 2.dp) + ) + Text( + text = "node_broadcast_interval_secs: $nodeBroadcastIntervalSecs", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 2.dp) + ) + // Neighbors: Each is 8 bytes (4 for node_id, 4 for snr) + val neighborCount = (rawBytes.size - 12) / 8 + if (neighborCount > 0) { + Text( + text = "Neighbors:", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) + } + for (n in 0 until neighborCount) { + val base = 12 + n * 8 + val neighborId = + (rawBytes[base].toUByte().toLong() or + (rawBytes[base + 1].toUByte().toLong() shl 8) or + (rawBytes[base + 2].toUByte().toLong() shl 16) or + (rawBytes[base + 3].toUByte().toLong() shl 24)) + // Next 4 bytes as Float (snr) + val snrBits = + (rawBytes[base + 4].toInt() and 0xFF) or + ((rawBytes[base + 5].toInt() and 0xFF) shl 8) or + ((rawBytes[base + 6].toInt() and 0xFF) shl 16) or + ((rawBytes[base + 7].toInt() and 0xFF) shl 24) + val snr = Float.fromBits(snrBits) + Text( + text = " - node_id: 0x${neighborId.toString(16)} = $neighborId, snr: $snr", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(start = 8.dp) + ) + } + if ((rawBytes.size - 12) % 8 != 0) { + Text( + text = "Warning: Remaining ${(rawBytes.size-12)%8} bytes could not be parsed as Neighbor.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 4.dp) + ) + } + } else { + Text( + text = "Not enough data for NeighborInfo protobuf header (need 12 bytes, got ${rawBytes.size})", + style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.error), + modifier = Modifier.padding(top = 8.dp) + ) + } + } + }, + dismissText = stringResource(id = R.string.okay), + onDismiss = { uIViewModel.clearNeighborInfoResponse() }, + ) + } val navSuiteType = NavigationSuiteScaffoldDefaults.navigationSuiteType(currentWindowAdaptiveInfo()) val currentDestination = navController.currentBackStackEntryAsState().value?.destination val topLevelDestination = TopLevelDestination.fromNavDestination(currentDestination) diff --git a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl index 1a8e83e97e..aa1a7b6860 100644 --- a/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl +++ b/core/service/src/main/aidl/org/meshtastic/core/service/IMeshService.aidl @@ -126,6 +126,9 @@ interface IMeshService { /// Send traceroute packet with wantResponse to nodeNum void requestTraceroute(in int requestId, in int destNum); + /// Send neighbor info packet with wantResponse to nodeNum + void requestNeighbourInfo(in int requestId, in int destNum); + /// Send Shutdown admin packet to nodeNum void requestShutdown(in int requestId, in int destNum); @@ -170,7 +173,4 @@ interface IMeshService { /// Send request for node UserInfo void requestUserInfo(in int destNum); - - /// Send request for node Neighbours - void requestNeighbourInfo(in int destNum); } diff --git a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt index 03041c4d6d..b14464092f 100644 --- a/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt +++ b/core/service/src/main/kotlin/org/meshtastic/core/service/ServiceRepository.kt @@ -106,6 +106,18 @@ class ServiceRepository @Inject constructor() { setTracerouteResponse(null) } + private val _neighborInfoResponse = MutableStateFlow(null) + val neighborInfoResponse: StateFlow + get() = _neighborInfoResponse + + fun setNeighborInfoResponse(value: String?) { + _neighborInfoResponse.value = value + } + + fun clearNeighborInfoResponse() { + setNeighborInfoResponse(null) + } + private val _serviceAction = Channel() val serviceAction = _serviceAction.receiveAsFlow() diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt index 5731d5ae72..a8a02d522f 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/detail/NodeDetailViewModel.kt @@ -114,7 +114,8 @@ constructor( private fun requestNeighbourInfo(destNum: Int) { Timber.i("Requesting NeighbourInfo for '$destNum'") try { - serviceRepository.meshService?.requestNeighbourInfo(destNum) + val packetId = serviceRepository.meshService?.packetId ?: return + serviceRepository.meshService?.requestNeighbourInfo(packetId, destNum) } catch (ex: RemoteException) { Timber.e("Request NeighbourInfo error: ${ex.message}") } From 4f87418e380fc522f3fc1a556e523dd43f8b32cb Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Tue, 28 Oct 2025 23:20:48 +1100 Subject: [PATCH 03/10] render traceroutes in the debug panel --- .../feature/settings/debugging/DebugViewModel.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index fabd3a0eb2..8c34a09a53 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository +import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed import org.meshtastic.proto.AdminProtos @@ -389,6 +390,17 @@ constructor( PortNum.PAXCOUNTER_APP_VALUE -> PaxcountProtos.Paxcount.parseFrom(payload).toString() PortNum.STORE_FORWARD_APP_VALUE -> StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString() + PortNum.TRACEROUTE_APP_VALUE -> { + val getUsername: (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 + } + packet.getTracerouteResponse(getUsername) + ?: runCatching { MeshProtos.RouteDiscovery.parseFrom(payload).toString() }.getOrNull() + ?: payload.joinToString(" ") { HEX_FORMAT.format(it) } + } else -> payload.joinToString(" ") { HEX_FORMAT.format(it) } } } catch (e: InvalidProtocolBufferException) { From d034f37b488397c98da01bdd5815f39afa1893b7 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Tue, 28 Oct 2025 23:28:52 +1100 Subject: [PATCH 04/10] add neighbor info rendering to the debug panel. --- .../settings/debugging/DebugViewModel.kt | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index 8c34a09a53..3e156aeb63 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -390,6 +390,27 @@ constructor( PortNum.PAXCOUNTER_APP_VALUE -> PaxcountProtos.Paxcount.parseFrom(payload).toString() PortNum.STORE_FORWARD_APP_VALUE -> StoreAndForwardProtos.StoreAndForward.parseFrom(payload).toString() + PortNum.NEIGHBORINFO_APP_VALUE -> { + val info = MeshProtos.NeighborInfo.parseFrom(payload) + val formatNode: (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: ${formatNode(info.nodeId)}") + appendLine("last_sent_by_id: ${formatNode(info.lastSentById)}") + appendLine("node_broadcast_interval_secs: ${info.nodeBroadcastIntervalSecs}") + if (info.neighborsCount > 0) { + appendLine("neighbors:") + info.neighborsList.forEach { n -> + appendLine(" - node_id: ${formatNode(n.nodeId)} snr: ${n.snr}") + } + } + } + } PortNum.TRACEROUTE_APP_VALUE -> { val getUsername: (Int) -> String = { nodeNum -> val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user From 3e6b5f96edebcdc3e6826f16efdb6df5ea09d081 Mon Sep 17 00:00:00 2001 From: Dane Evans Date: Tue, 28 Oct 2025 23:44:49 +1100 Subject: [PATCH 05/10] functioning, needs further testing --- .../geeksville/mesh/service/MeshService.kt | 31 +++- .../main/java/com/geeksville/mesh/ui/Main.kt | 135 ++++++------------ 2 files changed, 75 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index ce933ddc75..c3437c2e26 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -845,15 +845,40 @@ class MeshService : Service() { 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() + 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") - val neighborData = String(data.payload.toByteArray()) - "$neighborData\n\nDuration: ${"%.1f".format(seconds)} s" + "$formatted\n\nDuration: ${"%.1f".format(seconds)} s" } else { Timber.w("No start time found for neighbor info requestId: $requestId") - String(data.payload.toByteArray()) + formatted } serviceRepository.setNeighborInfoResponse(response) } diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 2bdceef483..597bef91df 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -242,119 +242,78 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode SimpleAlertDialog( title = R.string.neighbor_info, text = { - // Render the neighbor info response plainly - // Render the neighbor info response as a hex dump, interpreting any binary data as hex bytes. - // If the data appears to be binary, try to render its hex representation. Column(modifier = Modifier.fillMaxWidth()) { - val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' } - if (isBinary) { - // Render as hex string (show hex dump) - val hexString = response.toByteArray() - .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() - ) + fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? { + // First, try parsing directly from raw bytes of the string + runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) }.getOrNull()?.let { return it } + // 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() + if (hexPairs.size >= 4) { + val bytes = hexPairs.map { it.toInt(16).toByte() }.toByteArray() + runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) }.getOrNull()?.let { return it } + } + return null } - // The previous method tries to "find hex bytes" in the formatted response string, - // but since in 'isBinary' case we're showing our *own* hexString, let's directly use the bytes we made above for that. - // Use the *actual* binary: derive the nodeId from the first 4 bytes of the response, not the hex string. - - val rawBytes = response.toByteArray() - // For every 4-byte chunk, interpret as nodeId and output it. - // Interpret the byte array using the NeighborInfo protobuf layout. - // Expected: node_id (4 bytes), last_sent_by_id (4 bytes), node_broadcast_interval_secs (4 bytes), repeated Neighbor (8 bytes each: 4 for id, 4 for snr float) - if (rawBytes.size >= 12) { - val nodeId = - (rawBytes[0].toUByte().toLong() or - (rawBytes[1].toUByte().toLong() shl 8) or - (rawBytes[2].toUByte().toLong() shl 16) or - (rawBytes[3].toUByte().toLong() shl 24)) - val lastSentById = - (rawBytes[4].toUByte().toLong() or - (rawBytes[5].toUByte().toLong() shl 8) or - (rawBytes[6].toUByte().toLong() shl 16) or - (rawBytes[7].toUByte().toLong() shl 24)) - val nodeBroadcastIntervalSecs = - (rawBytes[8].toUByte().toLong() or - (rawBytes[9].toUByte().toLong() shl 8) or - (rawBytes[10].toUByte().toLong() shl 16) or - (rawBytes[11].toUByte().toLong() shl 24)) + 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: 0x${nodeId.toString(16)} = $nodeId", + text = "node_id: ${fmtNode(parsed.nodeId)}", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 8.dp) ) Text( - text = "last_sent_by_id: 0x${lastSentById.toString(16)} = $lastSentById", + text = "last_sent_by_id: ${fmtNode(parsed.lastSentById)}", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 2.dp) ) Text( - text = "node_broadcast_interval_secs: $nodeBroadcastIntervalSecs", + text = "node_broadcast_interval_secs: ${parsed.nodeBroadcastIntervalSecs}", style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 2.dp) ) - // Neighbors: Each is 8 bytes (4 for node_id, 4 for snr) - val neighborCount = (rawBytes.size - 12) / 8 - if (neighborCount > 0) { + if (parsed.neighborsCount > 0) { Text( - text = "Neighbors:", + 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) + ) + } } - for (n in 0 until neighborCount) { - val base = 12 + n * 8 - val neighborId = - (rawBytes[base].toUByte().toLong() or - (rawBytes[base + 1].toUByte().toLong() shl 8) or - (rawBytes[base + 2].toUByte().toLong() shl 16) or - (rawBytes[base + 3].toUByte().toLong() shl 24)) - // Next 4 bytes as Float (snr) - val snrBits = - (rawBytes[base + 4].toInt() and 0xFF) or - ((rawBytes[base + 5].toInt() and 0xFF) shl 8) or - ((rawBytes[base + 6].toInt() and 0xFF) shl 16) or - ((rawBytes[base + 7].toInt() and 0xFF) shl 24) - val snr = Float.fromBits(snrBits) + } else { + val rawBytes = response.toByteArray() + val isBinary = response.any { it.code < 32 && it != '\n' && it != '\r' && it != '\t' } + if (isBinary) { + val hexString = rawBytes.joinToString(" ") { "%02X".format(it) } Text( - text = " - node_id: 0x${neighborId.toString(16)} = $neighborId, snr: $snr", - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(start = 8.dp) + text = "Binary data (hex view):", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(bottom = 4.dp) ) - } - if ((rawBytes.size - 12) % 8 != 0) { Text( - text = "Warning: Remaining ${(rawBytes.size-12)%8} bytes could not be parsed as Neighbor.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 4.dp) + 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() ) } - } else { - Text( - text = "Not enough data for NeighborInfo protobuf header (need 12 bytes, got ${rawBytes.size})", - style = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.error), - modifier = Modifier.padding(top = 8.dp) - ) } } }, From 452766021c21a0cfa7cfea595e6d70fbad883330 Mon Sep 17 00:00:00 2001 From: Dane Date: Sat, 15 Nov 2025 22:16:19 +1100 Subject: [PATCH 06/10] rejig the request packet to make firmware happy --- .../geeksville/mesh/service/MeshService.kt | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index c3437c2e26..b1fc2b2f05 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -157,6 +157,7 @@ class MeshService : Service() { private val tracerouteStartTimes = ConcurrentHashMap() private val neighborInfoStartTimes = ConcurrentHashMap() + @Volatile private var lastNeighborInfo: MeshProtos.NeighborInfo? = null companion object { @@ -847,6 +848,20 @@ class MeshService : Service() { Timber.d("Found start time for requestId $requestId: $start") val info = runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() + + // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 + if (info != null && info.neighborsCount == 1 && + info.neighborsList[0].nodeId == 0 && info.neighborsList[0].snr == 0f) { + Timber.d("Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)") + return@let + } + + // 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") + } + val formatted = if (info != null) { val fmtNode: (Int) -> String = { nodeNum -> val user = nodeRepository.nodeDBbyNum.value[nodeNum]?.user @@ -2250,6 +2265,27 @@ class MeshService : Service() { override fun requestNeighbourInfo(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(3600) + .addNeighbors( + MeshProtos.Neighbor.newBuilder() + .setNodeId(0) // Dummy node ID that can be intercepted + .setSnr(0f) + .setLastRxTime(currentSecond()) + .setNodeBroadcastIntervalSecs(3600) + .build(), + ) + .build() + } + + // Send the neighbor info from our connected radio to the destination packetHandler.sendToRadio( newMeshPacketTo(destNum).buildMeshPacket( wantAck = true, @@ -2257,6 +2293,7 @@ class MeshService : Service() { channel = nodeDBbyNodeNum[destNum]?.channel ?: 0, ) { portnumValue = Portnums.PortNum.NEIGHBORINFO_APP_VALUE + payload = neighborInfoToSend.toByteString() wantResponse = true }, ) From a71e934d4d5e6cf286979e24e55799cd2c6e296d Mon Sep 17 00:00:00 2001 From: Dane Date: Sun, 16 Nov 2025 11:25:13 +1100 Subject: [PATCH 07/10] initial detekt pass --- .../geeksville/mesh/service/MeshService.kt | 196 ++++++++++-------- .../main/java/com/geeksville/mesh/ui/Main.kt | 43 ++-- .../settings/debugging/DebugViewModel.kt | 1 - 3 files changed, 132 insertions(+), 108 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 81474dcb02..5df95f2a5c 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -167,6 +167,7 @@ class MeshService : Service() { private val tracerouteStartTimes = ConcurrentHashMap() private val neighborInfoStartTimes = ConcurrentHashMap() + @Volatile private var lastNeighborInfo: MeshProtos.NeighborInfo? = null companion object { @@ -238,6 +239,8 @@ class MeshService : Service() { private val batteryPercentCooldownSeconds = 1500 private val batteryPercentCooldowns: HashMap = HashMap() + private val one_hour = 3600 + private fun getSenderName(packet: DataPacket?): String { val name = nodeDBbyID[packet?.from]?.user?.longName return name ?: getString(Res.string.unknown_username) @@ -875,54 +878,61 @@ class MeshService : Service() { val start = neighborInfoStartTimes.remove(requestId) Timber.d("Found start time for requestId $requestId: $start") - val info = runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() - + val info = + runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() + // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 - if (info != null && info.neighborsCount == 1 && - info.neighborsList[0].nodeId == 0 && info.neighborsList[0].snr == 0f) { + if ( + info != null && + info.neighborsCount == 1 && + info.neighborsList[0].nodeId == 0 && + info.neighborsList[0].snr == 0f + ) { Timber.d("Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)") return@let } - + // 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") } - - 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}") + + 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()) } - } 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 - } + 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) } @@ -932,54 +942,61 @@ class MeshService : Service() { val start = neighborInfoStartTimes.remove(requestId) Timber.d("Found start time for requestId $requestId: $start") - val info = runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() - + val info = + runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() + // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 - if (info != null && info.neighborsCount == 1 && - info.neighborsList[0].nodeId == 0 && info.neighborsList[0].snr == 0f) { + if ( + info != null && + info.neighborsCount == 1 && + info.neighborsList[0].nodeId == 0 && + info.neighborsList[0].snr == 0f + ) { Timber.d("Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)") return@let } - + // 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") } - - 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}") + + 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()) } - } 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 - } + 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) } @@ -2379,26 +2396,27 @@ class MeshService : Service() { override fun requestNeighbourInfo(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(3600) - .addNeighbors( - MeshProtos.Neighbor.newBuilder() - .setNodeId(0) // Dummy node ID that can be intercepted - .setSnr(0f) - .setLastRxTime(currentSecond()) - .setNodeBroadcastIntervalSecs(3600) - .build(), - ) - .build() - } - + 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(one_hour) + .addNeighbors( + MeshProtos.Neighbor.newBuilder() + .setNodeId(0) // Dummy node ID that can be intercepted + .setSnr(0f) + .setLastRxTime(currentSecond()) + .setNodeBroadcastIntervalSecs(one_hour) + .build(), + ) + .build() + } + // Send the neighbor info from our connected radio to the destination packetHandler.sendToRadio( newMeshPacketTo(destNum).buildMeshPacket( diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index c8e3bd8b02..12527fdff5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -31,10 +31,8 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer 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.recalculateWindowInsets import androidx.compose.foundation.layout.safeDrawingPadding @@ -47,7 +45,6 @@ import androidx.compose.material3.BadgedBox import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.PlainTooltip @@ -261,12 +258,21 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode Column(modifier = Modifier.fillMaxWidth()) { fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? { // First, try parsing directly from raw bytes of the string - runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) }.getOrNull()?.let { return it } + runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) } + .getOrNull() + ?.let { + return it + } // 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() - runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) }.getOrNull()?.let { return it } + runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) } + .getOrNull() + ?.let { + return it + } } return null } @@ -278,56 +284,57 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode Text( text = "node_id: ${fmtNode(parsed.nodeId)}", style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 8.dp) + 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) + modifier = Modifier.padding(top = 2.dp), ) Text( text = "node_broadcast_interval_secs: ${parsed.nodeBroadcastIntervalSecs}", style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 2.dp) + modifier = Modifier.padding(top = 2.dp), ) if (parsed.neighborsCount > 0) { Text( text = "neighbors:", style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding(top = 4.dp) + 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) + 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) + 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() + 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() + modifier = Modifier.padding(bottom = 8.dp).fillMaxWidth(), ) } } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt index d7abb80996..6d93f02f32 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/debugging/DebugViewModel.kt @@ -34,7 +34,6 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.meshtastic.core.data.repository.MeshLogRepository import org.meshtastic.core.data.repository.NodeRepository -import org.meshtastic.core.model.getTracerouteResponse import org.meshtastic.core.database.entity.MeshLog import org.meshtastic.core.database.entity.Packet import org.meshtastic.core.model.getTracerouteResponse From 886747f6c520529d0b01e81538acf031a7357295 Mon Sep 17 00:00:00 2001 From: Dane Date: Sun, 16 Nov 2025 11:43:48 +1100 Subject: [PATCH 08/10] detekt and spotless --- .../geeksville/mesh/service/MeshService.kt | 28 ++----------------- .../main/java/com/geeksville/mesh/ui/Main.kt | 27 ++++++++---------- 2 files changed, 15 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt index 5df95f2a5c..32b95bae51 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshService.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshService.kt @@ -239,7 +239,7 @@ class MeshService : Service() { private val batteryPercentCooldownSeconds = 1500 private val batteryPercentCooldowns: HashMap = HashMap() - private val one_hour = 3600 + private val oneHour = 3600 private fun getSenderName(packet: DataPacket?): String { val name = nodeDBbyID[packet?.from]?.user?.longName @@ -881,17 +881,6 @@ class MeshService : Service() { val info = runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() - // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 - if ( - info != null && - info.neighborsCount == 1 && - info.neighborsList[0].nodeId == 0 && - info.neighborsList[0].snr == 0f - ) { - Timber.d("Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)") - return@let - } - // Store the last neighbor info from our connected radio if (info != null && packet.from == myInfo.myNodeNum) { lastNeighborInfo = info @@ -945,17 +934,6 @@ class MeshService : Service() { val info = runCatching { MeshProtos.NeighborInfo.parseFrom(data.payload.toByteArray()) }.getOrNull() - // Ignore dummy/interceptable packets: single neighbor with nodeId 0 and snr 0 - if ( - info != null && - info.neighborsCount == 1 && - info.neighborsList[0].nodeId == 0 && - info.neighborsList[0].snr == 0f - ) { - Timber.d("Ignoring dummy neighbor info packet (single neighbor with nodeId 0, snr 0)") - return@let - } - // Store the last neighbor info from our connected radio if (info != null && packet.from == myInfo.myNodeNum) { lastNeighborInfo = info @@ -2405,13 +2383,13 @@ class MeshService : Service() { MeshProtos.NeighborInfo.newBuilder() .setNodeId(myNodeNum) .setLastSentById(myNodeNum) - .setNodeBroadcastIntervalSecs(one_hour) + .setNodeBroadcastIntervalSecs(oneHour) .addNeighbors( MeshProtos.Neighbor.newBuilder() .setNodeId(0) // Dummy node ID that can be intercepted .setSnr(0f) .setLastRxTime(currentSecond()) - .setNodeBroadcastIntervalSecs(one_hour) + .setNodeBroadcastIntervalSecs(oneHour) .build(), ) .build() diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 12527fdff5..60e388ab0d 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -258,23 +258,20 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode Column(modifier = Modifier.fillMaxWidth()) { fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? { // First, try parsing directly from raw bytes of the string - runCatching { MeshProtos.NeighborInfo.parseFrom(input.toByteArray()) } - .getOrNull() - ?.let { - return it + 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() } - // 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() - runCatching { MeshProtos.NeighborInfo.parseFrom(bytes) } - .getOrNull() - ?.let { - return it - } } - return null + + return neighborInfo } val parsed = tryParseNeighborInfo(response) From 613e2d8c389b4f23ae9651011570707c8d632845 Mon Sep 17 00:00:00 2001 From: Dane Date: Sun, 16 Nov 2025 12:02:56 +1100 Subject: [PATCH 09/10] fix string changes --- .../meshtastic/feature/node/component/RemoteDeviceActions.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt index e0934e35da..2ef66ccc57 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -59,7 +59,7 @@ internal fun RemoteDeviceActions(node: Node, lastTracerouteTime: Long?, onAction onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.TraceRoute(node))) }, ) ListItem( - text = stringResource(id = R.string.request_neighbor_info), + text = stringResource(Res.string.request_neighbor_info), leadingIcon = Icons.TwoTone.Mediation, trailingIcon = null, onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestNeighbourInfo(node))) }, From 353eff05b5f823380c90c75406133d0835cbc85f Mon Sep 17 00:00:00 2001 From: Dane Date: Sat, 6 Dec 2025 22:23:59 +1100 Subject: [PATCH 10/10] string alignment --- app/src/main/java/com/geeksville/mesh/ui/Main.kt | 6 ++++-- .../feature/node/component/RemoteDeviceActions.kt | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/geeksville/mesh/ui/Main.kt b/app/src/main/java/com/geeksville/mesh/ui/Main.kt index 289ff061c3..bbf7d69606 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/Main.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/Main.kt @@ -34,6 +34,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.rememberScrollState @@ -129,6 +130,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 @@ -256,7 +258,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode val neighborInfoResponse by uIViewModel.neighborInfoResponse.observeAsState() neighborInfoResponse?.let { response -> SimpleAlertDialog( - title = R.string.neighbor_info, + title = Res.string.neighbor_info, text = { Column(modifier = Modifier.fillMaxWidth()) { fun tryParseNeighborInfo(input: String): MeshProtos.NeighborInfo? { @@ -340,7 +342,7 @@ fun MainScreen(uIViewModel: UIViewModel = hiltViewModel(), scanModel: BTScanMode } } }, - dismissText = stringResource(id = R.string.okay), + dismissText = stringResource(Res.string.okay), onDismiss = { uIViewModel.clearNeighborInfoResponse() }, ) } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt index 2ef66ccc57..8e92a59727 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/RemoteDeviceActions.kt @@ -27,6 +27,7 @@ import org.meshtastic.core.database.model.Node import org.meshtastic.core.strings.Res import org.meshtastic.core.strings.direct_message import org.meshtastic.core.strings.exchange_userinfo +import org.meshtastic.core.strings.request_neighbor_info import org.meshtastic.core.ui.component.InsetDivider import org.meshtastic.core.ui.component.ListItem import org.meshtastic.feature.node.model.NodeDetailAction