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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user