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

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
}