mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
Feat request neighbours (#3709)
Signed-off-by: Dane Evans <dane@goneepic.com>
This commit is contained in:
parent
3e3dfe08e6
commit
d33229c50f
14 changed files with 375 additions and 10 deletions
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue