mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-06-14 00:45:24 -04:00
feat(discovery): mesh network discovery (#5275)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -106,6 +106,7 @@ class MeshDataHandlerTest {
|
||||
storeForwardHandler = storeForwardHandler,
|
||||
telemetryHandler = telemetryHandler,
|
||||
adminPacketHandler = adminPacketHandler,
|
||||
collectorRegistry = mock(MockMode.autofill),
|
||||
scope = testScope,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
@@ -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> =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
@@ -40,6 +40,7 @@ val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
|
||||
subclassesOfSealed<SettingsRoute>()
|
||||
subclassesOfSealed<FirmwareRoute>()
|
||||
subclassesOfSealed<WifiProvisionRoute>()
|
||||
subclassesOfSealed<DiscoveryRoute>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ","
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
Reference in New Issue
Block a user