From 67f444a927dddddcdc6b806f8593fb222cc816f4 Mon Sep 17 00:00:00 2001 From: James Rich Date: Wed, 29 Apr 2026 21:00:57 -0500 Subject: [PATCH] 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` --- .../core/database/dao/DiscoveryDao.kt | 9 + .../core/network/radio/BleRadioTransport.kt | 2 +- .../feature/discovery/DiscoveryScanEngine.kt | 258 +++++++++++------- .../discovery/DiscoveryScanEngineTest.kt | 55 ++-- 4 files changed, 215 insertions(+), 109 deletions(-) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt index dbf59a88b..3319d470d 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt @@ -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? diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index 9419862d0..95512ecf4 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -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() diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt index 74421bd3b..2d87da1ae 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt @@ -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() } diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt index a3620fd09..fc9e21199 100644 --- a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt +++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt @@ -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)