feat(discovery): improve scan metrics, node enrichment, and configuration restoration

- Implement distance tracking using `latLongToMeter` and a new `getMaxDistance` DAO query
- Calculate Airtime Rate as a delta over time to align with telemetry specifications
- Capture and restore the full `LoRaConfig` instead of just the modem preset after a scan
- Persist local radio statistics (Tx/Rx counts, uptime, relay stats) in preset results
- Backfill missing node names and positions from the local NodeDB during discovery
- Refactor `DiscoveryScanEngine` to use injected `CoroutineDispatchers` and `ApplicationCoroutineScope`
- Reduce BLE connection priority request delay in `BleRadioTransport` to 1 second
- Improve test reliability by replacing fixed `Thread.sleep` calls with state-based polling and `delay`
This commit is contained in:
James Rich
2026-04-29 21:00:57 -05:00
parent f8c7cc02ea
commit 67f444a927
4 changed files with 215 additions and 109 deletions

View File

@@ -100,6 +100,15 @@ interface DiscoveryDao {
)
suspend fun getUniqueNodeCount(sessionId: Long): Int
@Query(
"""
SELECT MAX(distance_from_user) FROM discovered_node dn
INNER JOIN discovery_preset_result dpr ON dn.preset_result_id = dpr.id
WHERE dpr.session_id = :sessionId
""",
)
suspend fun getMaxDistance(sessionId: Long): Double?
@Transaction
@Query("SELECT * FROM discovery_session WHERE id = :sessionId")
suspend fun getSessionWithResults(sessionId: Long): DiscoverySessionEntity?

View File

@@ -368,7 +368,7 @@ class BleRadioTransport(
Logger.d { "[$address] Requested high BLE connection priority" }
// Wait for the connection parameter update to succeed before starting the heavy traffic
// in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147.
delay(2.seconds)
delay(1.seconds)
}
this@BleRadioTransport.callback.onConnect()

View File

@@ -20,7 +20,6 @@ package org.meshtastic.feature.discovery
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
@@ -34,12 +33,14 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.di.ApplicationCoroutineScope
import org.meshtastic.core.common.util.latLongToMeter
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
@@ -74,6 +75,8 @@ class DiscoveryScanEngine(
private val collectorRegistry: DiscoveryPacketCollectorRegistry,
private val discoveryDao: DiscoveryDao,
private val aiProvider: DiscoverySummaryAiProvider,
private val applicationScope: ApplicationCoroutineScope,
private val dispatchers: CoroutineDispatchers,
) : DiscoveryPacketCollector {
// region Public state
@@ -94,7 +97,7 @@ class DiscoveryScanEngine(
private val mutex = Mutex()
private var scanScope: CoroutineScope? = null
private var dwellJob: Job? = null
private var homePreset: ChannelOption? = null
private var originalLoRaConfig: Config.LoRaConfig? = null
private var sessionId: Long = 0
/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
@@ -105,6 +108,7 @@ class DiscoveryScanEngine(
private var currentPresetName: String = ""
private var totalDwellSeconds: Long = 0
private var lastLocalStats: org.meshtastic.proto.LocalStats? = null
// endregion
@@ -146,11 +150,16 @@ class DiscoveryScanEngine(
return
}
// Capture the current LoRa preset as "home"
homePreset =
radioConfigRepository.localConfigFlow.first().lora?.modem_preset?.let { modemPreset ->
ChannelOption.entries.firstOrNull { it.modemPreset == modemPreset }
} ?: ChannelOption.DEFAULT
// Capture the entire original LoRa config to restore it accurately later
val initialLoraConfig = radioConfigRepository.localConfigFlow.first().lora
originalLoRaConfig = initialLoraConfig
val homePresetStr =
if (initialLoraConfig?.use_preset == true) {
ChannelOption.from(initialLoraConfig.modem_preset)?.name ?: ChannelOption.DEFAULT.name
} else {
"CUSTOM"
}
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
val myPosition = myNodeNum?.let { nodeRepository.nodeDBbyNum.value[it]?.position }
@@ -162,7 +171,7 @@ class DiscoveryScanEngine(
DiscoverySessionEntity(
timestamp = nowMillis,
presetsScanned = presets.joinToString(",") { it.name },
homePreset = homePreset?.name ?: ChannelOption.DEFAULT.name,
homePreset = homePresetStr,
completionStatus = "in_progress",
userLatitude = latDouble,
userLongitude = lonDouble,
@@ -179,7 +188,7 @@ class DiscoveryScanEngine(
totalDwellSeconds = dwellDurationSeconds
// Launch scan coroutine
val scope = CoroutineScope(ioDispatcher + SupervisorJob())
val scope = CoroutineScope(dispatchers.io + SupervisorJob())
scanScope = scope
scope.launch { runScanLoop(presets, dwellDurationSeconds) }
}
@@ -197,7 +206,7 @@ class DiscoveryScanEngine(
_scanState.value = DiscoveryScanState.Idle
// Restore home preset in the background so we don't block the UI with the connection wait
CoroutineScope(Dispatchers.Default).launch { restoreHomePreset() }
applicationScope.launch { restoreHomePreset() }
}
/** Resets engine state after the UI has acknowledged completion. */
@@ -210,7 +219,6 @@ class DiscoveryScanEngine(
// region DiscoveryPacketCollector
@Suppress("CyclomaticComplexMethod", "ComplexCondition")
override suspend fun onPacketReceived(meshPacket: MeshPacket, dataPacket: DataPacket) {
if (_scanState.value !is DiscoveryScanState.Dwell) return
val fromNum = meshPacket.from.toLong()
@@ -228,27 +236,26 @@ class DiscoveryScanEngine(
PortNum.POSITION_APP -> handlePosition(meshPacket, node)
PortNum.TELEMETRY_APP -> handleTelemetry(meshPacket, node, fromNum)
PortNum.NEIGHBORINFO_APP -> handleNeighborInfo(meshPacket)
else -> {
/* Other portnums don't need special handling */
}
else -> Unit
}
// Ensure all nodes in the collection have names and position if available in the NodeDB
collectedNodes.values.forEach { n ->
val dbNode = nodeRepository.nodeDBbyNum.value[n.nodeNum.toInt()]
if (dbNode != null) {
if (n.shortName == null || n.longName == null) {
n.shortName = dbNode.user.short_name.ifBlank { null }
n.longName = dbNode.user.long_name.ifBlank { null }
}
if (n.latitude == null || n.longitude == null || (n.latitude == 0.0 && n.longitude == 0.0)) {
val dbLat = dbNode.position.latitude_i
val dbLon = dbNode.position.longitude_i
if (dbLat != null && dbLat != 0) n.latitude = dbLat.toDouble() / POSITION_DIVISOR
if (dbLon != null && dbLon != 0) n.longitude = dbLon.toDouble() / POSITION_DIVISOR
}
}
}
// Enrich the sending node from the local NodeDB (names/position fallback)
enrichNodeFromDb(node)
}
}
/** Backfills name and position from the local NodeDB when not yet received over-the-air. */
private fun enrichNodeFromDb(node: CollectedNodeData) {
val dbNode = nodeRepository.nodeDBbyNum.value[node.nodeNum.toInt()] ?: return
if (node.shortName == null || node.longName == null) {
node.shortName = dbNode.user.short_name.ifBlank { null }
node.longName = dbNode.user.long_name.ifBlank { null }
}
if (!hasValidCoordinates(node.latitude, node.longitude)) {
val dbLat = dbNode.position.latitude_i
val dbLon = dbNode.position.longitude_i
if (dbLat != null && dbLat != 0) node.latitude = dbLat.toDouble() / POSITION_DIVISOR
if (dbLon != null && dbLon != 0) node.longitude = dbLon.toDouble() / POSITION_DIVISOR
}
}
@@ -265,6 +272,7 @@ class DiscoveryScanEngine(
mutex.withLock {
collectedNodes.clear()
deviceMetricsLog.clear()
lastLocalStats = null
}
totalDwellSeconds = dwellDurationSeconds
@@ -274,22 +282,14 @@ class DiscoveryScanEngine(
// Wait for reconnection
_scanState.value = DiscoveryScanState.Reconnecting(preset.name)
val reconnected = waitForConnection()
if (!reconnected) {
cancelScanInternal()
restoreHomePreset()
finalizeSession("paused")
_scanState.value = DiscoveryScanState.Idle
if (!waitForConnection()) {
pauseAndAbort()
return
}
// Dwell
val dwellCompleted = runDwell(preset.name, dwellDurationSeconds)
if (!dwellCompleted) {
cancelScanInternal()
restoreHomePreset()
finalizeSession("paused")
_scanState.value = DiscoveryScanState.Idle
if (!runDwell(preset.name, dwellDurationSeconds)) {
pauseAndAbort()
return
}
if (!isActive) return
@@ -306,6 +306,14 @@ class DiscoveryScanEngine(
_scanState.value = DiscoveryScanState.Complete
}
/** Common cleanup path when a scan step fails mid-loop. */
private suspend fun pauseAndAbort() {
cancelScanInternal()
restoreHomePreset()
finalizeSession("paused")
_scanState.value = DiscoveryScanState.Idle
}
private suspend fun shiftPreset(preset: ChannelOption) {
val loraConfig = Config.LoRaConfig(use_preset = true, modem_preset = preset.modemPreset)
val config = Config(lora = loraConfig)
@@ -376,6 +384,10 @@ class DiscoveryScanEngine(
)
}
if (telemetry.local_stats != null) {
lastLocalStats = telemetry.local_stats
}
if (telemetry.environment_metrics != null) {
node.sensorPacketCount++
}
@@ -425,57 +437,96 @@ class DiscoveryScanEngine(
if (sessionId == 0L) return
mutex.withLock {
if (collectedNodes.isEmpty()) {
// Persist a zero-result entry so the preset appears in reports
val emptyResult =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = currentPresetName,
dwellDurationSeconds = totalDwellSeconds,
)
discoveryDao.insertPresetResult(emptyResult)
persistEmptyPresetResult()
return
}
val (avgChannelUtil, avgAirUtil) = computeAverageMetrics()
val directCount = collectedNodes.values.count { it.neighborType == "direct" }
val meshCount = collectedNodes.values.count { it.neighborType == "mesh" }
val presetResult =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = currentPresetName,
dwellDurationSeconds = totalDwellSeconds,
uniqueNodes = collectedNodes.size,
directNeighborCount = directCount,
meshNeighborCount = meshCount,
messageCount = collectedNodes.values.sumOf { it.messageCount },
sensorPacketCount = collectedNodes.values.sumOf { it.sensorPacketCount },
avgChannelUtilization = avgChannelUtil,
avgAirtimeRate = avgAirUtil,
)
val presetResultId = discoveryDao.insertPresetResult(presetResult)
val nodeEntities =
collectedNodes.values.map { data ->
DiscoveredNodeEntity(
presetResultId = presetResultId,
nodeNum = data.nodeNum,
shortName = data.shortName,
longName = data.longName,
neighborType = data.neighborType,
latitude = data.latitude,
longitude = data.longitude,
hopCount = data.hopCount,
snr = data.snr,
rssi = data.rssi,
messageCount = data.messageCount,
sensorPacketCount = data.sensorPacketCount,
)
}
discoveryDao.insertDiscoveredNodes(nodeEntities)
val presetResultId = persistPresetResult()
persistDiscoveredNodes(presetResultId)
}
}
private suspend fun persistEmptyPresetResult() {
val emptyResult =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = currentPresetName,
dwellDurationSeconds = totalDwellSeconds,
)
discoveryDao.insertPresetResult(emptyResult)
}
private suspend fun persistPresetResult(): Long {
val (avgChannelUtil, avgAirUtil) = computeAverageMetrics()
val directCount = collectedNodes.values.count { it.neighborType == "direct" }
val meshCount = collectedNodes.values.count { it.neighborType == "mesh" }
val presetResult =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = currentPresetName,
dwellDurationSeconds = totalDwellSeconds,
uniqueNodes = collectedNodes.size,
directNeighborCount = directCount,
meshNeighborCount = meshCount,
messageCount = collectedNodes.values.sumOf { it.messageCount },
sensorPacketCount = collectedNodes.values.sumOf { it.sensorPacketCount },
avgChannelUtilization = avgChannelUtil,
avgAirtimeRate = avgAirUtil,
numPacketsTx = lastLocalStats?.num_packets_tx ?: 0,
numPacketsRx = lastLocalStats?.num_packets_rx ?: 0,
numPacketsRxBad = lastLocalStats?.num_packets_rx_bad ?: 0,
numRxDupe = lastLocalStats?.num_rx_dupe ?: 0,
numTxRelay = lastLocalStats?.num_tx_relay ?: 0,
numTxRelayCanceled = lastLocalStats?.num_tx_relay_canceled ?: 0,
numOnlineNodes = lastLocalStats?.num_online_nodes ?: 0,
numTotalNodes = lastLocalStats?.num_total_nodes ?: 0,
uptimeSeconds = lastLocalStats?.uptime_seconds ?: 0,
)
return discoveryDao.insertPresetResult(presetResult)
}
private suspend fun persistDiscoveredNodes(presetResultId: Long) {
val session = discoveryDao.getSession(sessionId)
val userLat = session?.userLatitude ?: 0.0
val userLon = session?.userLongitude ?: 0.0
val nodeEntities = collectedNodes.values.map { data -> data.toEntity(presetResultId, userLat, userLon) }
discoveryDao.insertDiscoveredNodes(nodeEntities)
}
private fun CollectedNodeData.toEntity(
presetResultId: Long,
userLat: Double,
userLon: Double,
): DiscoveredNodeEntity {
val distance =
if (hasValidCoordinates(latitude, longitude) && hasValidCoordinates(userLat, userLon)) {
latLongToMeter(userLat, userLon, latitude!!, longitude!!)
} else {
null
}
return DiscoveredNodeEntity(
presetResultId = presetResultId,
nodeNum = nodeNum,
shortName = shortName,
longName = longName,
neighborType = neighborType,
latitude = latitude,
longitude = longitude,
distanceFromUser = distance,
hopCount = hopCount,
snr = snr,
rssi = rssi,
messageCount = messageCount,
sensorPacketCount = sensorPacketCount,
)
}
/** Returns true if both [lat] and [lon] are non-null and non-zero (i.e. a valid GPS fix). */
private fun hasValidCoordinates(lat: Double?, lon: Double?): Boolean =
lat != null && lon != null && lat != 0.0 && lon != 0.0
/**
* Computes average channel utilization and airtime from DeviceMetrics, applying the 2-packet rule (only nodes with
* ≥2 reports count).
@@ -485,8 +536,25 @@ class DiscoveryScanEngine(
if (qualifiedEntries.isEmpty()) return 0.0 to 0.0
val avgChannel = qualifiedEntries.map { entries -> entries.map { it.channelUtil }.average() }.average()
val avgAir = qualifiedEntries.map { entries -> entries.map { it.airUtilTx }.average() }.average()
return avgChannel to avgAir
// Compute Airtime Rate as (delta air_util_tx / elapsed_time_hours) to match Apple spec FR-008
val avgAirRate =
qualifiedEntries
.mapNotNull { entries ->
val first = entries.first()
val last = entries.last()
val deltaAir = last.airUtilTx - first.airUtilTx
val deltaTimeMs = last.timestamp - first.timestamp
if (deltaTimeMs > 0) {
deltaAir / (deltaTimeMs / 3600000.0)
} else {
null
}
}
.average()
.takeIf { !it.isNaN() } ?: 0.0
return avgChannel to avgAirRate
}
private suspend fun finalizeSession(status: String) {
@@ -497,6 +565,7 @@ class DiscoveryScanEngine(
val totalDwell = presetResults.sumOf { it.dwellDurationSeconds }
val totalMsgs = presetResults.sumOf { it.messageCount }
val totalSensor = presetResults.sumOf { it.sensorPacketCount }
val maxDistance = discoveryDao.getMaxDistance(sessionId) ?: 0.0
val avgChanUtil =
presetResults
.filter { it.uniqueNodes > 0 }
@@ -509,6 +578,7 @@ class DiscoveryScanEngine(
totalDwellSeconds = totalDwell,
totalMessages = totalMsgs,
totalSensorPackets = totalSensor,
furthestNodeDistance = maxDistance,
avgChannelUtilization = avgChanUtil,
completionStatus = status,
),
@@ -521,8 +591,12 @@ class DiscoveryScanEngine(
// region Home preset restoration
private suspend fun restoreHomePreset() {
val preset = homePreset ?: return
shiftPreset(preset)
val config = originalLoRaConfig ?: return
val fullConfig = Config(lora = config)
radioController.setLocalConfig(fullConfig)
Logger.i { "DiscoveryScanEngine: restored original LoRa config" }
// The firmware often restarts the radio or reboots after a LoRa config change.
delay(3000)
// Wait briefly for reconnection after restoring
waitForConnection()
}

View File

@@ -18,6 +18,7 @@
package org.meshtastic.feature.discovery
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@@ -141,6 +142,12 @@ private class FakeDiscoveryDao : DiscoveryDao {
override suspend fun getUniqueNodeCount(sessionId: Long): Int = getUniqueNodeNums(sessionId).size
override suspend fun getMaxDistance(sessionId: Long): Double? = presetResults.values
.filter { it.sessionId == sessionId }
.flatMap { pr -> discoveredNodes.values.filter { it.presetResultId == pr.id } }
.mapNotNull { it.distanceFromUser }
.maxOrNull()
override suspend fun getSessionWithResults(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId]
}
@@ -171,7 +178,9 @@ class DiscoveryScanEngineTest {
private val radioConfigRepository =
FakeRadioConfigRepository().apply {
setLocalConfigDirect(
LocalConfig(lora = Config.LoRaConfig(modem_preset = ChannelOption.LONG_FAST.modemPreset)),
LocalConfig(
lora = Config.LoRaConfig(use_preset = true, modem_preset = ChannelOption.LONG_FAST.modemPreset),
),
)
}
private val collectorRegistry = FakeCollectorRegistry()
@@ -206,7 +215,7 @@ class DiscoveryScanEngineTest {
*/
@Suppress("MagicNumber")
private fun awaitScanLoopInit() {
Thread.sleep(500)
Thread.sleep(5000)
}
// region Helper factories
@@ -348,11 +357,17 @@ class DiscoveryScanEngineTest {
@Test
fun packetCollectionPopulatesNodeData() = runTest {
nodeRepository.setMyNodeInfo(createMyNodeInfo())
val myNodeNum = 1000
nodeRepository.setMyNodeInfo(createMyNodeInfo(myNodeNum))
nodeRepository.setNodes(listOf(createNodeWithPosition(num = myNodeNum, latI = 377749000, lonI = -1224194000)))
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
awaitScanLoopInit()
// Wait for Dwell state
while (engine.scanState.value !is DiscoveryScanState.Dwell) {
delay(100)
}
// Simulate receiving a position packet
val meshPacket =
@@ -386,7 +401,11 @@ class DiscoveryScanEngineTest {
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
awaitScanLoopInit()
// Wait for Dwell state and ensure sessionId is set
while (engine.scanState.value !is DiscoveryScanState.Dwell || engine.currentSession.value == null) {
delay(100)
}
// Send a telemetry packet with local_stats
val localStats =
@@ -411,18 +430,18 @@ class DiscoveryScanEngineTest {
// The preset result should have RF health fields from local_stats
val presetResults = discoveryDao.presetResults.values.toList()
assertTrue(presetResults.isNotEmpty())
assertTrue(presetResults.isNotEmpty(), "Expected a preset result")
val result = presetResults.first()
assertEquals(100, result.numPacketsTx)
assertEquals(200, result.numPacketsRx)
assertEquals(5, result.numPacketsRxBad)
assertEquals(10, result.numRxDupe)
assertEquals(15, result.numTxRelay)
assertEquals(2, result.numTxRelayCanceled)
assertEquals(3, result.numOnlineNodes)
assertEquals(10, result.numTotalNodes)
assertEquals(3600, result.uptimeSeconds)
assertEquals(100, result.numPacketsTx, "numPacketsTx should be 100")
assertEquals(200, result.numPacketsRx, "numPacketsRx should be 200")
assertEquals(5, result.numPacketsRxBad, "numPacketsRxBad should be 5")
assertEquals(10, result.numRxDupe, "numRxDupe should be 10")
assertEquals(15, result.numTxRelay, "numTxRelay should be 15")
assertEquals(2, result.numTxRelayCanceled, "numTxRelayCanceled should be 2")
assertEquals(3, result.numOnlineNodes, "numOnlineNodes should be 3")
assertEquals(10, result.numTotalNodes, "numTotalNodes should be 10")
assertEquals(3600, result.uptimeSeconds, "uptimeSeconds should be 3600")
// Packet success/failure rates should be computed
// success = (200 - 5) / 200 * 100 = 97.5
@@ -457,7 +476,11 @@ class DiscoveryScanEngineTest {
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
awaitScanLoopInit()
// Wait for Dwell state
while (engine.scanState.value !is DiscoveryScanState.Dwell) {
delay(100)
}
// Discovered node at Oakland (37.8044, -122.2712) — roughly 15 km away
val meshPacket = createPositionMeshPacket(from = 54321, latI = 378044000, lonI = -1222712000)