feat(discovery): mesh network discovery (#5275)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich
2026-06-05 04:51:37 -05:00
committed by James Rich
parent 285206a78d
commit a23e073003
90 changed files with 10848 additions and 55 deletions

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
@Single
class DiscoveryPacketCollectorRegistryImpl : DiscoveryPacketCollectorRegistry {
override var collector: DiscoveryPacketCollector? = null
}

View File

@@ -42,6 +42,7 @@ import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.model.util.toOneLiner
import org.meshtastic.core.repository.AdminPacketHandler
import org.meshtastic.core.repository.DataPair
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.repository.MeshDataHandler
import org.meshtastic.core.repository.MeshNotificationManager
import org.meshtastic.core.repository.MessageFilter
@@ -99,6 +100,7 @@ class MeshDataHandlerImpl(
private val storeForwardHandler: StoreForwardPacketHandler,
private val telemetryHandler: TelemetryPacketHandler,
private val adminPacketHandler: AdminPacketHandler,
private val collectorRegistry: DiscoveryPacketCollectorRegistry,
@Named("ServiceScope") private val scope: CoroutineScope,
) : MeshDataHandler {
@@ -118,6 +120,13 @@ class MeshDataHandlerImpl(
handleDataPacket(packet, dataPacket, myNodeNum, fromUs, logUuid, logInsertJob)
analytics.track("num_data_receive", DataPair("num_data_receive", 1))
// Forward to discovery scan collector if active
collectorRegistry.collector?.let { collector ->
if (collector.isActive) {
scope.handledLaunch { collector.onPacketReceived(packet, dataPacket) }
}
}
}
private fun handleDataPacket(

View File

@@ -106,6 +106,7 @@ class MeshDataHandlerTest {
storeForwardHandler = storeForwardHandler,
telemetryHandler = telemetryHandler,
adminPacketHandler = adminPacketHandler,
collectorRegistry = mock(MockMode.autofill),
scope = testScope,
)

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.database.MeshtasticDatabase
import org.meshtastic.core.database.MeshtasticDatabaseConstructor
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.robolectric.annotation.Config
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Migration coverage for discovery tables (D011).
*
* Verifies that the discovery schema (version 41→42 auto-migration) creates the expected tables, supports CRUD
* operations, enforces foreign key cascade behavior, and respects column defaults.
*/
@RunWith(AndroidJUnit4::class)
@Config(sdk = [34])
@Suppress("MagicNumber")
class DiscoveryMigrationTest {
private lateinit var database: MeshtasticDatabase
private lateinit var discoveryDao: DiscoveryDao
@Before
fun createDb() {
database =
Room.inMemoryDatabaseBuilder<MeshtasticDatabase>(factory = { MeshtasticDatabaseConstructor.initialize() })
.setDriver(BundledSQLiteDriver())
.build()
discoveryDao = database.discoveryDao()
}
@After
fun closeDb() {
database.close()
}
// region Table creation and basic CRUD
@Test
fun discoverySessionTable_insertAndRetrieve() = runTest {
val session =
DiscoverySessionEntity(
timestamp = 1_000_000L,
presetsScanned = "LONG_FAST,SHORT_FAST",
homePreset = "LONG_FAST",
completionStatus = "complete",
)
val id = discoveryDao.insertSession(session)
assertTrue(id > 0, "Insert should return positive auto-generated ID")
val loaded = discoveryDao.getSession(id)
assertNotNull(loaded)
assertEquals("LONG_FAST,SHORT_FAST", loaded.presetsScanned)
assertEquals("complete", loaded.completionStatus)
}
@Test
fun discoveryPresetResultTable_insertAndRetrieve() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val result =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = "LONG_FAST",
dwellDurationSeconds = 30,
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
)
val resultId = discoveryDao.insertPresetResult(result)
assertTrue(resultId > 0)
val results = discoveryDao.getPresetResults(sessionId)
assertEquals(1, results.size)
assertEquals("LONG_FAST", results[0].presetName)
assertEquals(5, results[0].uniqueNodes)
}
@Test
fun discoveredNodeTable_insertAndRetrieve() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
val node =
DiscoveredNodeEntity(
presetResultId = presetId,
nodeNum = 12345,
shortName = "TST",
longName = "Test Node",
neighborType = "direct",
latitude = 37.7749,
longitude = -122.4194,
snr = 8.5f,
rssi = -65,
)
val nodeId = discoveryDao.insertDiscoveredNode(node)
assertTrue(nodeId > 0)
val nodes = discoveryDao.getDiscoveredNodes(presetId)
assertEquals(1, nodes.size)
assertEquals(12345L, nodes[0].nodeNum)
assertEquals("direct", nodes[0].neighborType)
}
// endregion
// region Column defaults
@Test
fun sessionEntity_defaultValues() = runTest {
// Insert with only required fields — verify defaults
val session = DiscoverySessionEntity(timestamp = 1L, presetsScanned = "A", homePreset = "A")
val id = discoveryDao.insertSession(session)
val loaded = discoveryDao.getSession(id)!!
assertEquals(0, loaded.totalUniqueNodes)
assertEquals(0.0, loaded.avgChannelUtilization)
assertEquals(0, loaded.totalMessages)
assertEquals(0, loaded.totalSensorPackets)
assertEquals(0.0, loaded.furthestNodeDistance)
assertEquals("complete", loaded.completionStatus)
assertNull(loaded.aiSummary)
assertEquals(0.0, loaded.userLatitude)
assertEquals(0.0, loaded.userLongitude)
assertEquals(0L, loaded.totalDwellSeconds)
}
@Test
fun presetResultEntity_defaultValues() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val result = DiscoveryPresetResultEntity(sessionId = sessionId, presetName = "TEST")
val id = discoveryDao.insertPresetResult(result)
val loaded = discoveryDao.getPresetResults(sessionId).first { it.id == id }
assertEquals(0L, loaded.dwellDurationSeconds)
assertEquals(0, loaded.uniqueNodes)
assertEquals(0, loaded.directNeighborCount)
assertEquals(0, loaded.meshNeighborCount)
assertEquals(0, loaded.messageCount)
assertEquals(0, loaded.sensorPacketCount)
assertEquals(0.0, loaded.avgChannelUtilization)
assertEquals(0.0, loaded.avgAirtimeRate)
assertEquals(0.0, loaded.packetSuccessRate)
assertEquals(0.0, loaded.packetFailureRate)
assertEquals(0, loaded.numPacketsTx)
assertEquals(0, loaded.numPacketsRx)
assertEquals(0, loaded.numPacketsRxBad)
assertEquals(0, loaded.numRxDupe)
assertEquals(0, loaded.numTxRelay)
assertEquals(0, loaded.numTxRelayCanceled)
assertEquals(0, loaded.numOnlineNodes)
assertEquals(0, loaded.numTotalNodes)
assertEquals(0, loaded.uptimeSeconds)
assertNull(loaded.aiSummary)
}
@Test
fun discoveredNodeEntity_defaultValues() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
val node = DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 1)
val nodeId = discoveryDao.insertDiscoveredNode(node)
val loaded = discoveryDao.getDiscoveredNodes(presetId).first { it.id == nodeId }
assertNull(loaded.shortName)
assertNull(loaded.longName)
assertEquals("direct", loaded.neighborType)
assertNull(loaded.latitude)
assertNull(loaded.longitude)
assertNull(loaded.distanceFromUser)
assertEquals(0, loaded.hopCount)
assertEquals(0f, loaded.snr)
assertEquals(0, loaded.rssi)
assertEquals(0, loaded.messageCount)
assertEquals(0, loaded.sensorPacketCount)
}
// endregion
// region Foreign key cascade
@Test
fun deleteSession_cascadesPresetResultsAndNodes() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val presetId = discoveryDao.insertPresetResult(testPresetResult(sessionId))
discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 1))
discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = presetId, nodeNum = 2))
discoveryDao.deleteSession(sessionId)
assertNull(discoveryDao.getSession(sessionId))
assertTrue(discoveryDao.getPresetResults(sessionId).isEmpty())
assertTrue(discoveryDao.getDiscoveredNodes(presetId).isEmpty())
}
// endregion
// region Aggregate queries across migration-created schema
@Test
fun uniqueNodeCount_deduplicatesAcrossPresets() = runTest {
val sessionId = discoveryDao.insertSession(testSession())
val pre1 = discoveryDao.insertPresetResult(testPresetResult(sessionId, "LONG_FAST"))
val pre2 = discoveryDao.insertPresetResult(testPresetResult(sessionId, "SHORT_FAST"))
// Node 100 appears in both presets
discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre1, nodeNum = 100))
discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre1, nodeNum = 200))
discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre2, nodeNum = 100))
discoveryDao.insertDiscoveredNode(DiscoveredNodeEntity(presetResultId = pre2, nodeNum = 300))
assertEquals(3, discoveryDao.getUniqueNodeCount(sessionId))
}
@Test
fun getAllSessions_sortedNewestFirst() = runTest {
discoveryDao.insertSession(testSession(timestamp = 100))
discoveryDao.insertSession(testSession(timestamp = 300))
discoveryDao.insertSession(testSession(timestamp = 200))
val sessions = discoveryDao.getAllSessions().first()
assertEquals(listOf(300L, 200L, 100L), sessions.map { it.timestamp })
}
// endregion
// region Helpers
private fun testSession(timestamp: Long = 1_000_000L) = DiscoverySessionEntity(
timestamp = timestamp,
presetsScanned = "LONG_FAST",
homePreset = "LONG_FAST",
completionStatus = "in_progress",
)
private fun testPresetResult(sessionId: Long, presetName: String = "LONG_FAST") =
DiscoveryPresetResultEntity(sessionId = sessionId, presetName = presetName)
// endregion
}

View File

@@ -26,6 +26,7 @@ import androidx.room3.migration.AutoMigrationSpec
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.database.dao.DeviceHardwareDao
import org.meshtastic.core.database.dao.DeviceLinkDao
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -35,6 +36,9 @@ import org.meshtastic.core.database.dao.TracerouteNodePositionDao
import org.meshtastic.core.database.entity.ContactSettings
import org.meshtastic.core.database.entity.DeviceHardwareEntity
import org.meshtastic.core.database.entity.DeviceLinkEntity
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.database.entity.FirmwareReleaseEntity
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MetadataEntity
@@ -62,6 +66,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DeviceLinkEntity::class,
FirmwareReleaseEntity::class,
TracerouteNodePositionEntity::class,
DiscoverySessionEntity::class,
DiscoveryPresetResultEntity::class,
DiscoveredNodeEntity::class,
],
autoMigrations =
[
@@ -103,8 +110,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 38, to = 39),
AutoMigration(from = 39, to = 40),
AutoMigration(from = 40, to = 41),
AutoMigration(from = 41, to = 42),
],
version = 41,
version = 42,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -127,6 +135,8 @@ abstract class MeshtasticDatabase : RoomDatabase() {
abstract fun tracerouteNodePositionDao(): TracerouteNodePositionDao
abstract fun discoveryDao(): DiscoveryDao
companion object {
/** Configures a [RoomDatabase.Builder] with standard settings for this project. */
fun <T : RoomDatabase> RoomDatabase.Builder<T>.configureCommon(): RoomDatabase.Builder<T> =

View File

@@ -0,0 +1,120 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.dao
import androidx.room3.Dao
import androidx.room3.Insert
import androidx.room3.Query
import androidx.room3.Transaction
import androidx.room3.Update
import kotlinx.coroutines.flow.Flow
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
@Dao
@Suppress("TooManyFunctions")
interface DiscoveryDao {
// region Session operations
@Insert suspend fun insertSession(session: DiscoverySessionEntity): Long
@Update suspend fun updateSession(session: DiscoverySessionEntity)
@Query("SELECT * FROM discovery_session ORDER BY timestamp DESC")
fun getAllSessions(): Flow<List<DiscoverySessionEntity>>
@Query("SELECT * FROM discovery_session WHERE id = :sessionId")
suspend fun getSession(sessionId: Long): DiscoverySessionEntity?
@Query("SELECT * FROM discovery_session WHERE id = :sessionId")
fun getSessionFlow(sessionId: Long): Flow<DiscoverySessionEntity?>
@Query("DELETE FROM discovery_session WHERE id = :sessionId")
suspend fun deleteSession(sessionId: Long)
@Query("UPDATE discovery_session SET completion_status = 'interrupted' WHERE completion_status = 'in_progress'")
suspend fun markInterruptedSessions()
// endregion
// region Preset result operations
@Insert suspend fun insertPresetResult(result: DiscoveryPresetResultEntity): Long
@Update suspend fun updatePresetResult(result: DiscoveryPresetResultEntity)
@Query("SELECT * FROM discovery_preset_result WHERE session_id = :sessionId")
suspend fun getPresetResults(sessionId: Long): List<DiscoveryPresetResultEntity>
@Query("SELECT * FROM discovery_preset_result WHERE session_id = :sessionId")
fun getPresetResultsFlow(sessionId: Long): Flow<List<DiscoveryPresetResultEntity>>
// endregion
// region Discovered node operations
@Insert suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long
@Insert suspend fun insertDiscoveredNodes(nodes: List<DiscoveredNodeEntity>)
@Update suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity)
@Query("SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId")
suspend fun getDiscoveredNodes(presetResultId: Long): List<DiscoveredNodeEntity>
@Query("SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId")
fun getDiscoveredNodesFlow(presetResultId: Long): Flow<List<DiscoveredNodeEntity>>
@Query(
"""
SELECT DISTINCT node_num FROM discovered_node dn
INNER JOIN discovery_preset_result dpr ON dn.preset_result_id = dpr.id
WHERE dpr.session_id = :sessionId
""",
)
suspend fun getUniqueNodeNums(sessionId: Long): List<Long>
// endregion
// region Aggregate queries
@Query(
"""
SELECT COUNT(DISTINCT node_num) FROM discovered_node dn
INNER JOIN discovery_preset_result dpr ON dn.preset_result_id = dpr.id
WHERE dpr.session_id = :sessionId
""",
)
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?
// endregion
}

View File

@@ -17,10 +17,13 @@
package org.meshtastic.core.database.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Factory
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.database.DatabaseProvider
import org.meshtastic.core.database.createDatabaseDataStore
import org.meshtastic.core.database.dao.DiscoveryDao
@Module
@ComponentScan("org.meshtastic.core.database")
@@ -28,4 +31,8 @@ class CoreDatabaseModule {
@Single
@Named("DatabaseDataStore")
fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs")
@Factory
fun provideDiscoveryDao(databaseProvider: DatabaseProvider): DiscoveryDao =
databaseProvider.currentDb.value.discoveryDao()
}

View File

@@ -0,0 +1,54 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
@Entity(
tableName = "discovered_node",
foreignKeys =
[
ForeignKey(
entity = DiscoveryPresetResultEntity::class,
parentColumns = ["id"],
childColumns = ["preset_result_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["preset_result_id"]), Index(value = ["node_num"])],
)
data class DiscoveredNodeEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "preset_result_id") val presetResultId: Long,
@ColumnInfo(name = "node_num") val nodeNum: Long,
@ColumnInfo(name = "short_name") val shortName: String? = null,
@ColumnInfo(name = "long_name") val longName: String? = null,
@ColumnInfo(name = "neighbor_type", defaultValue = "'direct'") val neighborType: String = "direct",
@ColumnInfo(name = "latitude") val latitude: Double? = null,
@ColumnInfo(name = "longitude") val longitude: Double? = null,
@ColumnInfo(name = "distance_from_user") val distanceFromUser: Double? = null,
@ColumnInfo(name = "hop_count", defaultValue = "0") val hopCount: Int = 0,
@ColumnInfo(name = "snr", defaultValue = "0") val snr: Float = 0f,
@ColumnInfo(name = "rssi", defaultValue = "0") val rssi: Int = 0,
@ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
@ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
@ColumnInfo(name = "is_infrastructure", defaultValue = "0") val isInfrastructure: Boolean = false,
)

View File

@@ -0,0 +1,63 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.ForeignKey
import androidx.room3.Index
import androidx.room3.PrimaryKey
@Entity(
tableName = "discovery_preset_result",
foreignKeys =
[
ForeignKey(
entity = DiscoverySessionEntity::class,
parentColumns = ["id"],
childColumns = ["session_id"],
onDelete = ForeignKey.CASCADE,
),
],
indices = [Index(value = ["session_id"])],
)
data class DiscoveryPresetResultEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "session_id") val sessionId: Long,
@ColumnInfo(name = "preset_name") val presetName: String,
@ColumnInfo(name = "dwell_duration_seconds", defaultValue = "0") val dwellDurationSeconds: Long = 0,
@ColumnInfo(name = "unique_nodes", defaultValue = "0") val uniqueNodes: Int = 0,
@ColumnInfo(name = "direct_neighbor_count", defaultValue = "0") val directNeighborCount: Int = 0,
@ColumnInfo(name = "mesh_neighbor_count", defaultValue = "0") val meshNeighborCount: Int = 0,
@ColumnInfo(name = "infrastructure_node_count", defaultValue = "0") val infrastructureNodeCount: Int = 0,
@ColumnInfo(name = "message_count", defaultValue = "0") val messageCount: Int = 0,
@ColumnInfo(name = "sensor_packet_count", defaultValue = "0") val sensorPacketCount: Int = 0,
@ColumnInfo(name = "avg_channel_utilization", defaultValue = "0.0") val avgChannelUtilization: Double = 0.0,
@ColumnInfo(name = "avg_airtime_rate", defaultValue = "0.0") val avgAirtimeRate: Double = 0.0,
@ColumnInfo(name = "packet_success_rate", defaultValue = "0.0") val packetSuccessRate: Double = 0.0,
@ColumnInfo(name = "packet_failure_rate", defaultValue = "0.0") val packetFailureRate: Double = 0.0,
@ColumnInfo(name = "ai_summary") val aiSummary: String? = null,
@ColumnInfo(name = "num_packets_tx", defaultValue = "0") val numPacketsTx: Int = 0,
@ColumnInfo(name = "num_packets_rx", defaultValue = "0") val numPacketsRx: Int = 0,
@ColumnInfo(name = "num_packets_rx_bad", defaultValue = "0") val numPacketsRxBad: Int = 0,
@ColumnInfo(name = "num_rx_dupe", defaultValue = "0") val numRxDupe: Int = 0,
@ColumnInfo(name = "num_tx_relay", defaultValue = "0") val numTxRelay: Int = 0,
@ColumnInfo(name = "num_tx_relay_canceled", defaultValue = "0") val numTxRelayCanceled: Int = 0,
@ColumnInfo(name = "num_online_nodes", defaultValue = "0") val numOnlineNodes: Int = 0,
@ColumnInfo(name = "num_total_nodes", defaultValue = "0") val numTotalNodes: Int = 0,
@ColumnInfo(name = "uptime_seconds", defaultValue = "0") val uptimeSeconds: Int = 0,
)

View File

@@ -0,0 +1,39 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.database.entity
import androidx.room3.ColumnInfo
import androidx.room3.Entity
import androidx.room3.PrimaryKey
@Entity(tableName = "discovery_session")
data class DiscoverySessionEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "timestamp") val timestamp: Long,
@ColumnInfo(name = "presets_scanned") val presetsScanned: String,
@ColumnInfo(name = "home_preset") val homePreset: String,
@ColumnInfo(name = "total_unique_nodes", defaultValue = "0") val totalUniqueNodes: Int = 0,
@ColumnInfo(name = "avg_channel_utilization", defaultValue = "0.0") val avgChannelUtilization: Double = 0.0,
@ColumnInfo(name = "total_messages", defaultValue = "0") val totalMessages: Int = 0,
@ColumnInfo(name = "total_sensor_packets", defaultValue = "0") val totalSensorPackets: Int = 0,
@ColumnInfo(name = "furthest_node_distance", defaultValue = "0.0") val furthestNodeDistance: Double = 0.0,
@ColumnInfo(name = "completion_status", defaultValue = "'complete'") val completionStatus: String = "complete",
@ColumnInfo(name = "ai_summary") val aiSummary: String? = null,
@ColumnInfo(name = "user_latitude", defaultValue = "0.0") val userLatitude: Double = 0.0,
@ColumnInfo(name = "user_longitude", defaultValue = "0.0") val userLongitude: Double = 0.0,
@ColumnInfo(name = "total_dwell_seconds", defaultValue = "0") val totalDwellSeconds: Long = 0,
)

View File

@@ -0,0 +1,302 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.core.database.dao
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.database.MeshtasticDatabase
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.database.getInMemoryDatabaseBuilder
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
abstract class CommonDiscoveryDaoTest {
private lateinit var database: MeshtasticDatabase
private lateinit var dao: DiscoveryDao
suspend fun createDb() {
database = getInMemoryDatabaseBuilder().build()
dao = database.discoveryDao()
}
@AfterTest
fun closeDb() {
database.close()
}
// region Session CRUD
@Test
fun insertSession_returnsAutoGeneratedId() = runTest {
val session = testSession(timestamp = 1_000_000L)
val id = dao.insertSession(session)
assertTrue(id > 0, "Auto-generated id should be > 0")
}
@Test
fun getSession_returnsInsertedSession() = runTest {
val id = dao.insertSession(testSession(timestamp = 2_000_000L, homePreset = "MEDIUM_SLOW"))
val loaded = dao.getSession(id)
assertNotNull(loaded)
assertEquals(id, loaded.id)
assertEquals("MEDIUM_SLOW", loaded.homePreset)
assertEquals(2_000_000L, loaded.timestamp)
}
@Test fun getSession_returnsNullForMissing() = runTest { assertNull(dao.getSession(999L)) }
@Test
fun updateSession_modifiesExistingRow() = runTest {
val id = dao.insertSession(testSession(timestamp = 3_000_000L))
val original = dao.getSession(id)!!
dao.updateSession(original.copy(completionStatus = "stopped", totalUniqueNodes = 5))
val updated = dao.getSession(id)!!
assertEquals("stopped", updated.completionStatus)
assertEquals(5, updated.totalUniqueNodes)
}
@Test
fun deleteSession_removesRow() = runTest {
val id = dao.insertSession(testSession())
dao.deleteSession(id)
assertNull(dao.getSession(id))
}
// endregion
// region Session sort order (getAllSessions returns newest-first)
@Test
fun getAllSessions_orderedByTimestampDescending() = runTest {
dao.insertSession(testSession(timestamp = 100L))
dao.insertSession(testSession(timestamp = 300L))
dao.insertSession(testSession(timestamp = 200L))
val sessions = dao.getAllSessions().first()
assertEquals(3, sessions.size)
assertEquals(300L, sessions[0].timestamp)
assertEquals(200L, sessions[1].timestamp)
assertEquals(100L, sessions[2].timestamp)
}
// endregion
// region Preset result relation loading
@Test
fun getPresetResults_returnsResultsForSession() = runTest {
val sessionId = dao.insertSession(testSession())
dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
dao.insertPresetResult(testPresetResult(sessionId, presetName = "SHORT_FAST"))
val results = dao.getPresetResults(sessionId)
assertEquals(2, results.size)
assertTrue(results.any { it.presetName == "LONG_FAST" })
assertTrue(results.any { it.presetName == "SHORT_FAST" })
}
@Test
fun getPresetResults_doesNotReturnOtherSessionResults() = runTest {
val session1 = dao.insertSession(testSession(timestamp = 1L))
val session2 = dao.insertSession(testSession(timestamp = 2L))
dao.insertPresetResult(testPresetResult(session1, presetName = "A"))
dao.insertPresetResult(testPresetResult(session2, presetName = "B"))
val results = dao.getPresetResults(session1)
assertEquals(1, results.size)
assertEquals("A", results[0].presetName)
}
@Test
fun getPresetResultsFlow_emitsOnInsert() = runTest {
val sessionId = dao.insertSession(testSession())
val initial = dao.getPresetResultsFlow(sessionId).first()
assertTrue(initial.isEmpty())
dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
val updated = dao.getPresetResultsFlow(sessionId).first()
assertEquals(1, updated.size)
}
// endregion
// region Discovered node relation loading
@Test
fun getDiscoveredNodes_returnsNodesForPresetResult() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 100))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 200))
val nodes = dao.getDiscoveredNodes(presetId)
assertEquals(2, nodes.size)
}
@Test
fun insertDiscoveredNodes_batchInsert() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
val batch =
listOf(testNode(presetId, nodeNum = 1), testNode(presetId, nodeNum = 2), testNode(presetId, nodeNum = 3))
dao.insertDiscoveredNodes(batch)
assertEquals(3, dao.getDiscoveredNodes(presetId).size)
}
@Test
fun updateDiscoveredNode_modifiesExistingRow() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
val nodeId = dao.insertDiscoveredNode(testNode(presetId, nodeNum = 42))
val original = dao.getDiscoveredNodes(presetId).first { it.id == nodeId }
dao.updateDiscoveredNode(original.copy(snr = 12.5f, rssi = -55))
val updated = dao.getDiscoveredNodes(presetId).first { it.id == nodeId }
assertEquals(12.5f, updated.snr)
assertEquals(-55, updated.rssi)
}
// endregion
// region Cascade deletion
@Test
fun deleteSession_cascadesPresetResults() = runTest {
val sessionId = dao.insertSession(testSession())
dao.insertPresetResult(testPresetResult(sessionId, presetName = "LONG_FAST"))
dao.insertPresetResult(testPresetResult(sessionId, presetName = "SHORT_FAST"))
dao.deleteSession(sessionId)
assertTrue(dao.getPresetResults(sessionId).isEmpty(), "Preset results should be cascade-deleted")
}
@Test
fun deleteSession_cascadesDiscoveredNodes() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 2))
dao.deleteSession(sessionId)
assertTrue(dao.getDiscoveredNodes(presetId).isEmpty(), "Discovered nodes should be cascade-deleted")
}
@Test
fun deleteSession_doesNotAffectOtherSessions() = runTest {
val session1 = dao.insertSession(testSession(timestamp = 1L))
val session2 = dao.insertSession(testSession(timestamp = 2L))
val preset1 = dao.insertPresetResult(testPresetResult(session1))
val preset2 = dao.insertPresetResult(testPresetResult(session2))
dao.insertDiscoveredNode(testNode(preset1, nodeNum = 1))
dao.insertDiscoveredNode(testNode(preset2, nodeNum = 2))
dao.deleteSession(session1)
assertNotNull(dao.getSession(session2))
assertEquals(1, dao.getPresetResults(session2).size)
assertEquals(1, dao.getDiscoveredNodes(preset2).size)
}
// endregion
// region Aggregate queries
@Test
fun getUniqueNodeCount_countsAcrossPresets() = runTest {
val sessionId = dao.insertSession(testSession())
val preset1 = dao.insertPresetResult(testPresetResult(sessionId, presetName = "A"))
val preset2 = dao.insertPresetResult(testPresetResult(sessionId, presetName = "B"))
// Same node 100 appears in both presets
dao.insertDiscoveredNode(testNode(preset1, nodeNum = 100))
dao.insertDiscoveredNode(testNode(preset1, nodeNum = 200))
dao.insertDiscoveredNode(testNode(preset2, nodeNum = 100))
dao.insertDiscoveredNode(testNode(preset2, nodeNum = 300))
assertEquals(3, dao.getUniqueNodeCount(sessionId), "Node 100 appears in both presets but should count once")
}
@Test
fun getUniqueNodeNums_returnsDistinctNodeNums() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 10))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 20))
val nums = dao.getUniqueNodeNums(sessionId)
assertEquals(setOf(10L, 20L), nums.toSet())
}
@Test
fun getMaxDistance_returnsLargestDistance() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1, distanceFromUser = 500.0))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 2, distanceFromUser = 15_000.0))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 3, distanceFromUser = 3_000.0))
assertEquals(15_000.0, dao.getMaxDistance(sessionId))
}
@Test
fun getMaxDistance_returnsNullWhenNoNodes() = runTest {
val sessionId = dao.insertSession(testSession())
assertNull(dao.getMaxDistance(sessionId))
}
@Test
fun getMaxDistance_returnsNullWhenAllDistancesNull() = runTest {
val sessionId = dao.insertSession(testSession())
val presetId = dao.insertPresetResult(testPresetResult(sessionId))
dao.insertDiscoveredNode(testNode(presetId, nodeNum = 1, distanceFromUser = null))
assertNull(dao.getMaxDistance(sessionId))
}
// endregion
// region Flow queries
@Test
fun getSessionFlow_emitsUpdatesOnChange() = runTest {
val id = dao.insertSession(testSession(timestamp = 5_000_000L))
val initial = dao.getSessionFlow(id).first()
assertNotNull(initial)
assertEquals("in_progress", initial.completionStatus)
}
// endregion
// region Helpers
private fun testSession(timestamp: Long = 1_000_000L, homePreset: String = "LONG_FAST") = DiscoverySessionEntity(
timestamp = timestamp,
presetsScanned = "LONG_FAST,SHORT_FAST",
homePreset = homePreset,
completionStatus = "in_progress",
)
private fun testPresetResult(sessionId: Long, presetName: String = "LONG_FAST") = DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = presetName,
dwellDurationSeconds = 30,
uniqueNodes = 5,
)
private fun testNode(presetResultId: Long, nodeNum: Long, distanceFromUser: Double? = null) = DiscoveredNodeEntity(
presetResultId = presetResultId,
nodeNum = nodeNum,
snr = 5.0f,
rssi = -70,
distanceFromUser = distanceFromUser,
)
// endregion
}

View File

@@ -132,7 +132,7 @@ object DeepLinkRouter {
}
}
@Suppress("ReturnCount", "MagicNumber")
@Suppress("MagicNumber", "ReturnCount")
private fun routeSettings(segments: List<String>): List<NavKey> {
var destNum: Int? = null
var subRouteStr: String? = null
@@ -165,6 +165,20 @@ object DeepLinkRouter {
}
}
// Handle discovery session deep links: /settings/local-mesh-discovery/session/{sessionId}
if (subRouteStr in discoveryAliases && segments.size > 3 && segments[2].lowercase() == "session") {
val sessionId = segments[3].toLongOrNull()
return if (sessionId != null) {
listOf(
SettingsRoute.Settings(destNum),
DiscoveryRoute.DiscoveryGraph,
DiscoveryRoute.DiscoverySummary(sessionId),
)
} else {
listOf(SettingsRoute.Settings(destNum), DiscoveryRoute.DiscoveryGraph)
}
}
val subRoute = settingsSubRoutes[subRouteStr]
return if (subRoute != null) {
listOf(SettingsRoute.Settings(destNum), subRoute)
@@ -224,8 +238,13 @@ object DeepLinkRouter {
"filter-settings" to SettingsRoute.FilterSettings,
"helpdocs" to SettingsRoute.HelpDocs,
"help-docs" to SettingsRoute.HelpDocs,
"local-mesh-discovery" to DiscoveryRoute.DiscoveryGraph,
"localmeshdiscovery" to DiscoveryRoute.DiscoveryGraph,
)
/** URL path segments that map to the discovery feature. */
private val discoveryAliases = setOf("local-mesh-discovery", "localmeshdiscovery")
private val nodeDetailSubRoutes: Map<String, (Int) -> Route> =
mapOf(
"device-metrics" to { destNum -> NodeDetailRoute.DeviceMetrics(destNum) },

View File

@@ -40,6 +40,7 @@ val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
subclassesOfSealed<SettingsRoute>()
subclassesOfSealed<FirmwareRoute>()
subclassesOfSealed<WifiProvisionRoute>()
subclassesOfSealed<DiscoveryRoute>()
}
}
}

View File

@@ -198,3 +198,18 @@ sealed interface WifiProvisionRoute : Route {
@Serializable data class WifiProvision(val address: String? = null) : WifiProvisionRoute
}
@Serializable
sealed interface DiscoveryRoute : Route {
@Serializable data object DiscoveryGraph : DiscoveryRoute, Graph
@Serializable data object DiscoveryScan : DiscoveryRoute
@Serializable data class DiscoverySummary(val sessionId: Long) : DiscoveryRoute
@Serializable data object DiscoveryHistory : DiscoveryRoute
@Serializable data class DiscoveryHistoryDetail(val sessionId: Long) : DiscoveryRoute
@Serializable data class DiscoveryMap(val sessionId: Long) : DiscoveryRoute
}

View File

@@ -380,6 +380,40 @@ class DeepLinkRouterTest {
// endregion
// region discovery deep links
@Test
fun `discovery settings sub-route navigates to discovery graph`() {
val result = route("/settings/local-mesh-discovery")
assertEquals(listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph), result)
}
@Test
fun `discovery session deep link resolves session ID`() {
val result = route("/settings/local-mesh-discovery/session/42")
assertEquals(
listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph, DiscoveryRoute.DiscoverySummary(42L)),
result,
)
}
@Test
fun `discovery alias localmeshdiscovery resolves session ID`() {
val result = route("/settings/localmeshdiscovery/session/99")
assertEquals(
listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph, DiscoveryRoute.DiscoverySummary(99L)),
result,
)
}
@Test
fun `discovery session with invalid ID falls back to graph`() {
val result = route("/settings/local-mesh-discovery/session/notanumber")
assertEquals(listOf(SettingsRoute.Settings(null), DiscoveryRoute.DiscoveryGraph), result)
}
// endregion
// region case insensitivity
@Test

View File

@@ -0,0 +1,86 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.prefs.discovery
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.DiscoveryPrefs
@Single
class DiscoveryPrefsImpl(
@Named("UiDataStore") private val dataStore: DataStore<Preferences>,
dispatchers: CoroutineDispatchers,
) : DiscoveryPrefs {
private val scope = CoroutineScope(SupervisorJob() + dispatchers.default)
override val dwellMinutes: StateFlow<Int> =
dataStore.data
.map { it[KEY_DWELL_MINUTES] ?: DiscoveryPrefs.DEFAULT_DWELL_MINUTES }
.stateIn(scope, SharingStarted.Eagerly, DiscoveryPrefs.DEFAULT_DWELL_MINUTES)
override fun setDwellMinutes(minutes: Int) {
scope.launch { dataStore.edit { it[KEY_DWELL_MINUTES] = minutes } }
}
override val selectedPresets: StateFlow<Set<String>> =
dataStore.data
.map { prefs ->
val raw = prefs[KEY_SELECTED_PRESETS] ?: ""
if (raw.isBlank()) emptySet() else raw.split(PRESET_DELIMITER).toSet()
}
.stateIn(scope, SharingStarted.Eagerly, emptySet())
override fun setSelectedPresets(presets: Set<String>) {
scope.launch { dataStore.edit { it[KEY_SELECTED_PRESETS] = presets.joinToString(PRESET_DELIMITER) } }
}
override val aiEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_AI_ENABLED] ?: true }.stateIn(scope, SharingStarted.Eagerly, true)
override fun setAiEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_AI_ENABLED] = enabled } }
}
override val topologyOverlayEnabled: StateFlow<Boolean> =
dataStore.data.map { it[KEY_TOPOLOGY_OVERLAY] ?: false }.stateIn(scope, SharingStarted.Eagerly, false)
override fun setTopologyOverlayEnabled(enabled: Boolean) {
scope.launch { dataStore.edit { it[KEY_TOPOLOGY_OVERLAY] = enabled } }
}
companion object {
private val KEY_DWELL_MINUTES = intPreferencesKey("discovery_dwell_minutes")
private val KEY_SELECTED_PRESETS = stringPreferencesKey("discovery_selected_presets")
private val KEY_AI_ENABLED = booleanPreferencesKey("discovery_ai_enabled")
private val KEY_TOPOLOGY_OVERLAY = booleanPreferencesKey("discovery_topology_overlay")
private const val PRESET_DELIMITER = ","
}
}

View File

@@ -354,4 +354,28 @@ interface AppPreferences {
val radio: RadioPrefs
val mesh: MeshPrefs
val tak: TakPrefs
val discovery: DiscoveryPrefs
}
/** Reactive interface for Local Mesh Discovery scan preferences. */
interface DiscoveryPrefs {
val dwellMinutes: StateFlow<Int>
fun setDwellMinutes(minutes: Int)
val selectedPresets: StateFlow<Set<String>>
fun setSelectedPresets(presets: Set<String>)
val aiEnabled: StateFlow<Boolean>
fun setAiEnabled(enabled: Boolean)
val topologyOverlayEnabled: StateFlow<Boolean>
fun setTopologyOverlayEnabled(enabled: Boolean)
companion object {
const val DEFAULT_DWELL_MINUTES = 15
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshPacket
/**
* Interface for collecting packets during an active discovery scan. The scan engine implements this interface and
* registers/unregisters with the packet handler to receive packets during dwell windows.
*/
interface DiscoveryPacketCollector {
/** Whether this collector is currently active (scan in progress). */
val isActive: Boolean
/**
* Called when a mesh packet is received during an active scan. Implementations should classify and aggregate the
* packet data.
*
* @param meshPacket The raw mesh packet from the radio
* @param dataPacket The decoded data packet with routing info
*/
suspend fun onPacketReceived(meshPacket: MeshPacket, dataPacket: DataPacket)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.repository
/**
* Registry for discovery packet collectors. The scan engine registers itself when a scan starts and unregisters when it
* stops. The packet handler checks for an active collector and forwards packets to it.
*/
interface DiscoveryPacketCollectorRegistry {
/** The currently registered collector, or null if no scan is active. */
var collector: DiscoveryPacketCollector?
}

View File

@@ -362,6 +362,81 @@
<string name="disconnect">Disconnect</string>
<string name="disconnected">Disconnected</string>
<string name="discovered_network_devices">Discovered Network Devices</string>
<!-- DISCOVERY -->
<string name="discovery_analysing_results">Analyzing results</string>
<string name="discovery_cancelling_scan">Cancelling scan</string>
<string name="discovery_connection_warning">Not connected. Connect to a Meshtastic device to start scanning.</string>
<string name="discovery_delete_session">Delete Session</string>
<string name="discovery_delete_session_confirm">Are you sure you want to delete this discovery session? This action cannot be undone.</string>
<string name="discovery_dwell_minutes">%1$d min</string>
<string name="discovery_dwell_progress">Dwelling on %1$s, %2$s remaining</string>
<string name="discovery_dwell_time">Dwell Time</string>
<string name="discovery_dwell_time_description">Time to listen on each preset</string>
<string name="discovery_empty_history">No discovery sessions yet</string>
<string name="discovery_export_report">Export report</string>
<string name="discovery_history">Discovery History</string>
<string name="discovery_keep_screen_awake">Keep screen awake</string>
<string name="discovery_keep_screen_awake_description">Prevents Android Doze mode from dropping radio packets during long scans. Recommended.</string>
<string name="discovery_local_mesh">Local Mesh Discovery</string>
<string name="discovery_lora_presets">LoRa Presets</string>
<string name="discovery_lora_presets_description">Select one or more presets to scan</string>
<string name="discovery_map">Discovery Map</string>
<string name="discovery_not_connected">Not Connected</string>
<string name="discovery_not_connected_description">Connect to a Meshtastic device to start scanning.</string>
<string name="discovery_paused">Paused: %1$s</string>
<string name="discovery_preparing">Preparing scan</string>
<string name="discovery_preset_home_label">%1$s (Home)</string>
<string name="discovery_reconnecting">Reconnecting on %1$s</string>
<string name="discovery_rerun_analysis">Re-run analysis</string>
<string name="discovery_restoring_preset">Restoring home preset</string>
<string name="discovery_scan_complete">Session complete</string>
<string name="discovery_scan_failed">Scan failed: %1$s</string>
<string name="discovery_scan_history">Scan History</string>
<string name="discovery_scan_incomplete">Session incomplete</string>
<string name="discovery_scan_progress">Scan Progress</string>
<string name="discovery_scan_summary">Scan Summary</string>
<string name="discovery_session_detail">Session Detail</string>
<string name="discovery_shifting_to">Shifting to %1$s</string>
<string name="discovery_start_scan">Start Scan</string>
<string name="discovery_start_scan_disabled">Start scan button disabled. %1$s</string>
<string name="discovery_start_scan_reason_24ghz_unsupported">radio hardware does not support 2.4 GHz</string>
<string name="discovery_start_scan_reason_default_key">channel uses default encryption key</string>
<string name="discovery_start_scan_reason_no_presets">no presets selected</string>
<string name="discovery_start_scan_reason_not_connected">device not connected</string>
<string name="discovery_stat_analysis">Analysis</string>
<string name="discovery_stat_avg_airtime_rate">Avg airtime rate</string>
<string name="discovery_stat_avg_channel_utilization">Avg channel utilization</string>
<string name="discovery_stat_bad_packets">Bad packets</string>
<string name="discovery_stat_channel_utilization">Channel utilization</string>
<string name="discovery_stat_date">Date</string>
<string name="discovery_stat_direct">Direct</string>
<string name="discovery_stat_duplicate_packets">Duplicate packets</string>
<string name="discovery_stat_dwelling_on">Dwelling on %1$s</string>
<string name="discovery_stat_failure_rate">Failure rate</string>
<string name="discovery_stat_home_preset">Home preset</string>
<string name="discovery_stat_mesh">Mesh</string>
<string name="discovery_stat_messages">Messages</string>
<string name="discovery_stat_online_total_nodes">Online / Total nodes</string>
<string name="discovery_stat_packets_rx">Packets RX</string>
<string name="discovery_stat_packets_tx">Packets TX</string>
<string name="discovery_stat_preset_results">Preset Results</string>
<string name="discovery_stat_presets_scanned">Presets scanned</string>
<string name="discovery_stat_rf_health">RF Health</string>
<string name="discovery_stat_selected">Selected</string>
<string name="discovery_stat_sensor_pkts">Sensor pkts</string>
<string name="discovery_stat_session_overview">Session Overview</string>
<string name="discovery_stat_status">Status</string>
<string name="discovery_stat_success_rate">Success rate</string>
<string name="discovery_stat_total_dwell_time">Total dwell time</string>
<string name="discovery_stat_total_messages">Total messages</string>
<string name="discovery_stat_total_unique_nodes">Total unique nodes</string>
<string name="discovery_stat_unique_nodes">Unique nodes</string>
<string name="discovery_stat_unselected">Not selected</string>
<string name="discovery_stop_scan">Stop Scan</string>
<string name="discovery_summary_not_available">AI analysis not available</string>
<string name="discovery_time_remaining">%1$s remaining</string>
<string name="discovery_unique_nodes">%1$d unique nodes</string>
<string name="discovery_view_map">View map</string>
<string name="disk_free_indexed">Disk Free %1$d</string>
<!-- DISPLAY -->
<string name="display">Display</string>

View File

@@ -413,6 +413,33 @@ class FakeAppPreferences : AppPreferences {
override val radio = FakeRadioPrefs()
override val mesh = FakeMeshPrefs()
override val tak = FakeTakPrefs()
override val discovery = FakeDiscoveryPrefs()
}
class FakeDiscoveryPrefs : org.meshtastic.core.repository.DiscoveryPrefs {
override val dwellMinutes = MutableStateFlow(org.meshtastic.core.repository.DiscoveryPrefs.DEFAULT_DWELL_MINUTES)
override fun setDwellMinutes(minutes: Int) {
dwellMinutes.value = minutes
}
override val selectedPresets = MutableStateFlow<Set<String>>(emptySet())
override fun setSelectedPresets(presets: Set<String>) {
selectedPresets.value = presets
}
override val aiEnabled = MutableStateFlow(true)
override fun setAiEnabled(enabled: Boolean) {
aiEnabled.value = enabled
}
override val topologyOverlayEnabled = MutableStateFlow(false)
override fun setTopologyOverlayEnabled(enabled: Boolean) {
topologyOverlayEnabled.value = enabled
}
}
class FakeTakPrefs : org.meshtastic.core.repository.TakPrefs {

View File

@@ -201,6 +201,14 @@ object StatusColors {
}
}
@Suppress("MagicNumber")
object DiscoveryMapColors {
val DirectNode = Color(0xFF4CAF50)
val MeshNode = Color(0xFF2196F3)
val UserPosition = Color(0xFFFF9800)
val DirectLine = Color(0x804CAF50)
}
object MessageItemColors {
val Red = Color(0x4DFF0000)
}

View File

@@ -0,0 +1,46 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
/** Neighbor type classification for discovery map markers. */
enum class DiscoveryNeighborType {
DIRECT,
MESH,
}
/**
* Platform-neutral representation of a discovered node for map rendering. Contains only the data needed to place and
* style a marker — no Room entities or platform types leak into the map provider API.
*/
data class DiscoveryMapNode(
val latitude: Double,
val longitude: Double,
val shortName: String?,
val longName: String?,
val neighborType: DiscoveryNeighborType,
val snr: Float = 0f,
val rssi: Int = 0,
val messageCount: Int = 0,
val sensorPacketCount: Int = 0,
) {
/**
* FR-011: Map icon classification. If environment packets > text messages, return true (sensor). Otherwise return
* false (social/chat).
*/
val isSensorNode: Boolean
get() = sensorPacketCount > messageCount
}

View File

@@ -0,0 +1,48 @@
/*
* Copyright (c) 2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.component.PlaceholderScreen
/**
* Provides an embeddable discovery map composable that renders discovered node markers and topology polylines for a
* Local Mesh Discovery scan session. Unlike [LocalMapViewProvider], this does **not** include node clustering,
* waypoints, location tracking, or any main-map features — it is designed to be embedded inside the discovery summary
* scaffold.
*
* Parameters:
* - `userLatitude` / `userLongitude`: The scanner's position at scan time (orange marker).
* - `nodes`: Platform-neutral [DiscoveryMapNode] list for marker placement and styling.
* - `modifier`: Compose modifier for the map.
*
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
*/
@Suppress("Wrapping", "CompositionLocalAllowlist")
val LocalDiscoveryMapProvider =
compositionLocalOf<
@Composable (
userLatitude: Double,
userLongitude: Double,
nodes: List<DiscoveryMapNode>,
modifier: Modifier,
) -> Unit,
> {
{ _, _, _, _ -> PlaceholderScreen("Discovery Map") }
}