Refactor command handling, enhance tests, and improve discovery logic (#4878)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-03-22 00:42:27 -05:00 committed by GitHub
parent d136b162a4
commit c38bfc64de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 2220 additions and 1277 deletions

View file

@ -29,7 +29,6 @@ import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.network.repository.UsbRepository
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioInterfaceService
@ -55,7 +54,6 @@ class AndroidGetDiscoveredDevicesUseCase(
private val radioInterfaceService: RadioInterfaceService,
private val usbManagerLazy: Lazy<UsbManager>,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
private val macSuffixLength = 8
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -69,24 +67,8 @@ class AndroidGetDiscoveredDevicesUseCase(
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { String(it, Charsets.UTF_8) } ?: getString(Res.string.meshtastic)
val deviceId = idBytes?.let { String(it, Charsets.UTF_8) }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
val defaultName = getString(Res.string.meshtastic)
processTcpServices(tcpServices, recentList, defaultName)
}
val usbDevicesFlow =
@ -131,6 +113,7 @@ class AndroidGetDiscoveredDevicesUseCase(
@Suppress("UNCHECKED_CAST", "MagicNumber")
val recentList = args[5] as List<RecentAddress>
// Android-specific: BLE node matching by MAC suffix and Meshtastic short name
val bleForUi =
bondedBle
.map { entry ->
@ -153,61 +136,20 @@ class AndroidGetDiscoveredDevicesUseCase(
}
.sortedBy { it.name }
// Android-specific: USB node matching via shared helper
val usbForUi =
(
usbDevices +
if (showMock) listOf(DeviceListEntry.Mock(getString(Res.string.demo_mode))) else emptyList()
)
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
db.values.find { node ->
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, db, databaseManager))
}
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { String(it, Charsets.UTF_8) }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
// Shared TCP logic via helpers
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase(Locale.ROOT)
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase(Locale.ROOT).endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
DiscoveredDevices(
bleDevices = bleForUi,

View file

@ -23,7 +23,6 @@ import org.koin.core.annotation.Single
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.RecentAddressesDataSource
import org.meshtastic.core.network.repository.NetworkRepository
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.demo_mode
@ -40,9 +39,7 @@ class CommonGetDiscoveredDevicesUseCase(
private val networkRepository: NetworkRepository,
private val usbScanner: UsbScanner? = null,
) : GetDiscoveredDevicesUseCase {
private val suffixLength = 4
@Suppress("LongMethod", "CyclomaticComplexMethod")
override fun invoke(showMock: Boolean): Flow<DiscoveredDevices> {
val nodeDb = nodeRepository.nodeDBbyNum
val usbFlow = usbScanner?.scanUsbDevices() ?: kotlinx.coroutines.flow.flowOf(emptyList())
@ -52,25 +49,8 @@ class CommonGetDiscoveredDevicesUseCase(
tcpServices,
recentList,
->
val recentMap = recentList.associateBy({ it.address }) { it.name }
tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName =
shortNameBytes?.let { it.decodeToString() }
?: runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
val deviceId = idBytes?.let { it.decodeToString() }?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && (displayName.split("_").none { it == deviceId })) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
val defaultName = runCatching { getString(Res.string.meshtastic) }.getOrDefault("Meshtastic")
processTcpServices(tcpServices, recentList, defaultName)
}
return combine(
@ -80,42 +60,9 @@ class CommonGetDiscoveredDevicesUseCase(
recentAddressesDataSource.recentAddresses,
usbFlow,
) { db, processedTcp, resolved, recentList, usbList ->
val discoveredTcpForUi =
processedTcp.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolved.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.let { it.decodeToString() }
db.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
val discoveredTcpForUi = matchDiscoveredTcpNodes(processedTcp, db, resolved, databaseManager)
val discoveredTcpAddresses = processedTcp.map { it.fullAddress }.toSet()
val recentTcpForUi =
recentList
.filterNot { discoveredTcpAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val suffix = entry.name.split("_").lastOrNull()?.lowercase()
db.values.find { node ->
suffix != null &&
suffix.length >= suffixLength &&
node.user.id.lowercase().endsWith(suffix)
}
} else {
null
}
entry.copy(node = matchingNode)
}
.sortedBy { it.name }
val recentTcpForUi = buildRecentTcpEntries(recentList, discoveredTcpAddresses, db, databaseManager)
DiscoveredDevices(
discoveredTcpDevices = discoveredTcpForUi,

View file

@ -0,0 +1,112 @@
/*
* Copyright (c) 2025-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.feature.connections.domain.usecase
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.model.Node
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.network.repository.NetworkRepository.Companion.toAddressString
import org.meshtastic.feature.connections.model.DeviceListEntry
private const val SUFFIX_LENGTH = 4
/**
* Shared helpers for TCP device discovery logic used by both [CommonGetDiscoveredDevicesUseCase] and the
* Android-specific variant.
*/
/** Converts a list of [DiscoveredService] into [DeviceListEntry.Tcp] with display names derived from TXT records. */
internal fun processTcpServices(
tcpServices: List<DiscoveredService>,
recentAddresses: List<RecentAddress>,
defaultShortName: String = "Meshtastic",
): List<DeviceListEntry.Tcp> {
val recentMap = recentAddresses.associateBy({ it.address }) { it.name }
return tcpServices
.map { service ->
val address = "t${service.toAddressString()}"
val txtRecords = service.txt
val shortNameBytes = txtRecords["shortname"]
val idBytes = txtRecords["id"]
val shortName = shortNameBytes?.decodeToString() ?: defaultShortName
val deviceId = idBytes?.decodeToString()?.replace("!", "")
var displayName = recentMap[address] ?: shortName
if (deviceId != null && displayName.split("_").none { it == deviceId }) {
displayName += "_$deviceId"
}
DeviceListEntry.Tcp(displayName, address)
}
.sortedBy { it.name }
}
/** Matches each discovered TCP entry to a [Node] from the database using its mDNS device ID. */
internal fun matchDiscoveredTcpNodes(
entries: List<DeviceListEntry.Tcp>,
nodeDb: Map<Int, Node>,
resolvedServices: List<DiscoveredService>,
databaseManager: DatabaseManager,
): List<DeviceListEntry.Tcp> = entries.map { entry ->
val matchingNode =
if (databaseManager.hasDatabaseFor(entry.fullAddress)) {
val resolvedService = resolvedServices.find { "t${it.toAddressString()}" == entry.fullAddress }
val deviceId = resolvedService?.txt?.get("id")?.decodeToString()
nodeDb.values.find { node ->
node.user.id == deviceId || (deviceId != null && node.user.id == "!$deviceId")
}
} else {
null
}
entry.copy(node = matchingNode)
}
/**
* Builds the "recent TCP devices" list by filtering out currently discovered addresses and matching each entry to a
* [Node] by name suffix.
*/
internal fun buildRecentTcpEntries(
recentAddresses: List<RecentAddress>,
discoveredAddresses: Set<String>,
nodeDb: Map<Int, Node>,
databaseManager: DatabaseManager,
): List<DeviceListEntry.Tcp> = recentAddresses
.filterNot { discoveredAddresses.contains(it.address) }
.map { DeviceListEntry.Tcp(it.name, it.address) }
.map { entry ->
entry.copy(node = findNodeByNameSuffix(entry.name, entry.fullAddress, nodeDb, databaseManager))
}
.sortedBy { it.name }
/**
* Finds a [Node] matching the last `_`-delimited segment of [displayName], if a local database exists for the given
* [fullAddress]. Used by both TCP recent-device matching and Android USB device matching to avoid duplicated
* suffix-lookup logic.
*/
internal fun findNodeByNameSuffix(
displayName: String,
fullAddress: String,
nodeDb: Map<Int, Node>,
databaseManager: DatabaseManager,
): Node? {
val suffix = displayName.split("_").lastOrNull()?.lowercase()
return if (!databaseManager.hasDatabaseFor(fullAddress) || suffix == null || suffix.length < SUFFIX_LENGTH) {
null
} else {
nodeDb.values.find { it.user.id.lowercase().endsWith(suffix) }
}
}

View file

@ -47,7 +47,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
@ -86,7 +85,6 @@ import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.feature.settings.navigation.getNavRouteFrom
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.feature.settings.radio.component.PacketResponseStateDialog
import org.meshtastic.proto.Config
import kotlin.uuid.ExperimentalUuidApi
/** Composable screen for managing device connections (BLE, TCP, USB). It displays connection status. */
@ -102,25 +100,12 @@ fun ConnectionsScreen(
onConfigNavigate: (Route) -> Unit,
) {
val radioConfigState by radioConfigViewModel.radioConfigState.collectAsStateWithLifecycle()
val config by connectionsViewModel.localConfig.collectAsStateWithLifecycle()
val scanStatusText by scanModel.errorText.collectAsStateWithLifecycle()
val connectionState by connectionsViewModel.connectionState.collectAsStateWithLifecycle()
// Prevent continuous recomposition from lastHeard and snr updates on the node
val ourNode by
remember(connectionsViewModel.ourNodeInfo) {
connectionsViewModel.ourNodeInfo.distinctUntilChanged { old, new ->
old?.num == new?.num &&
old?.user == new?.user &&
old?.batteryLevel == new?.batteryLevel &&
old?.voltage == new?.voltage &&
old?.metadata?.firmware_version == new?.metadata?.firmware_version
}
}
.collectAsStateWithLifecycle(initialValue = connectionsViewModel.ourNodeInfo.value)
val ourNode by connectionsViewModel.ourNodeForDisplay.collectAsStateWithLifecycle()
val regionUnset by connectionsViewModel.regionUnset.collectAsStateWithLifecycle()
val selectedDevice by scanModel.selectedNotNullFlow.collectAsStateWithLifecycle()
val regionUnset = config.lora?.region == Config.LoRaConfig.RegionCode.UNSET
val bleDevices by scanModel.bleDevicesForUi.collectAsStateWithLifecycle()
val discoveredTcpDevices by scanModel.discoveredTcpDevicesForUi.collectAsStateWithLifecycle()
@ -192,63 +177,31 @@ fun ConnectionsScreen(
Crossfade(targetState = uiState, label = "connection_state") { state ->
when (state) {
2 -> {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice =
bleDevices.find { it.fullAddress == selectedDevice }
as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
2 ->
ConnectedDeviceContent(
ourNode = ourNode,
regionUnset = regionUnset,
selectedDevice = selectedDevice,
bleDevices = bleDevices,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = { scanModel.disconnect() },
onSetRegion = {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
},
)
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
) {
isWaiting = true
radioConfigViewModel.setResponseStateLoading(ConfigRoute.LORA)
}
}
}
}
}
1 ->
ConnectingDeviceContent(
selectedDevice = selectedDevice,
bleDevices = bleDevices,
discoveredTcpDevices = discoveredTcpDevices,
recentTcpDevices = recentTcpDevices,
usbDevices = usbDevices,
onClickDisconnect = { scanModel.disconnect() },
)
1 -> {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(
deviceName = name,
deviceAddress = address,
onClickDisconnect = { scanModel.disconnect() },
)
}
}
else -> {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}
else -> NoDeviceContent()
}
}
@ -334,3 +287,74 @@ fun ConnectionsScreen(
}
}
}
/** Content shown when connected to a device with node info available. */
@Composable
private fun ConnectedDeviceContent(
ourNode: org.meshtastic.core.model.Node?,
regionUnset: Boolean,
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
onNavigateToNodeDetails: (Int) -> Unit,
onClickDisconnect: () -> Unit,
onSetRegion: () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
ourNode?.let { node ->
TitledCard(title = stringResource(Res.string.connected_device)) {
CurrentlyConnectedInfo(
node = node,
bleDevice = bleDevices.find { it.fullAddress == selectedDevice } as DeviceListEntry.Ble?,
onNavigateToNodeDetails = onNavigateToNodeDetails,
onClickDisconnect = onClickDisconnect,
)
}
}
if (regionUnset && selectedDevice != "m") {
TitledCard(title = null) {
ListItem(
leadingIcon = Icons.Rounded.Language,
text = stringResource(Res.string.set_your_region),
onClick = onSetRegion,
)
}
}
}
}
/** Content shown when connecting or a device is selected but node info is not yet available. */
@Composable
private fun ConnectingDeviceContent(
selectedDevice: String,
bleDevices: List<DeviceListEntry>,
discoveredTcpDevices: List<DeviceListEntry>,
recentTcpDevices: List<DeviceListEntry>,
usbDevices: List<DeviceListEntry>,
onClickDisconnect: () -> Unit,
) {
val selectedEntry =
bleDevices.find { it.fullAddress == selectedDevice }
?: discoveredTcpDevices.find { it.fullAddress == selectedDevice }
?: recentTcpDevices.find { it.fullAddress == selectedDevice }
?: usbDevices.find { it.fullAddress == selectedDevice }
val name = selectedEntry?.name ?: stringResource(Res.string.unknown_device)
val address = selectedEntry?.address ?: selectedDevice
TitledCard(title = stringResource(Res.string.connected_device)) {
ConnectingDeviceInfo(deviceName = name, deviceAddress = address, onClickDisconnect = onClickDisconnect)
}
}
/** Content shown when no device is selected. */
@Composable
private fun NoDeviceContent() {
Card(modifier = Modifier.fillMaxWidth()) {
EmptyStateContent(
imageVector = MeshtasticIcons.NoDevice,
text = stringResource(Res.string.no_device_selected),
modifier = Modifier.height(160.dp),
)
}
}

View file

@ -0,0 +1,232 @@
/*
* 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.feature.connections.domain.usecase
import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.matcher.any
import dev.mokkery.mock
import io.kotest.matchers.shouldBe
import org.meshtastic.core.common.database.DatabaseManager
import org.meshtastic.core.datastore.model.RecentAddress
import org.meshtastic.core.network.repository.DiscoveredService
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.feature.connections.model.DeviceListEntry
import kotlin.test.Test
import kotlin.test.assertNotNull
import kotlin.test.assertNull
/** Unit tests for the shared TCP discovery helper functions. */
class TcpDiscoveryHelpersTest {
@Test
fun `processTcpServices maps services to DeviceListEntry with shortname and id`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_abcd",
hostAddress = "192.168.1.10",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!abcd".encodeToByteArray()),
),
)
val result = processTcpServices(services, emptyList())
result.size shouldBe 1
result[0].name shouldBe "Mesh_abcd"
result[0].fullAddress shouldBe "t192.168.1.10"
}
@Test
fun `processTcpServices uses default shortname when missing`() {
val services =
listOf(DiscoveredService(name = "TestDevice", hostAddress = "10.0.0.1", port = 4403, txt = emptyMap()))
val result = processTcpServices(services, emptyList(), defaultShortName = "Meshtastic")
result.size shouldBe 1
result[0].name shouldBe "Meshtastic"
}
@Test
fun `processTcpServices uses recent name over shortname`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray()),
),
)
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "MyNode"))
val result = processTcpServices(services, recentAddresses)
result.size shouldBe 1
result[0].name shouldBe "MyNode"
}
@Test
fun `processTcpServices does not duplicate id in display name`() {
val services =
listOf(
DiscoveredService(
name = "Meshtastic_1234",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("shortname" to "Mesh".encodeToByteArray(), "id" to "!1234".encodeToByteArray()),
),
)
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "Mesh_1234"))
val result = processTcpServices(services, recentAddresses)
result.size shouldBe 1
// Should NOT become "Mesh_1234_1234"
result[0].name shouldBe "Mesh_1234"
}
@Test
fun `processTcpServices results are sorted by name`() {
val services =
listOf(
DiscoveredService("Z", "10.0.0.2", 4403, mapOf("shortname" to "Zulu".encodeToByteArray())),
DiscoveredService("A", "10.0.0.1", 4403, mapOf("shortname" to "Alpha".encodeToByteArray())),
)
val result = processTcpServices(services, emptyList())
result[0].name shouldBe "Alpha"
result[1].name shouldBe "Zulu"
}
@Test
fun `matchDiscoveredTcpNodes matches node by device id`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
val nodeDb = mapOf(1 to node)
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
val resolved =
listOf(
DiscoveredService(
name = "Meshtastic",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray()),
),
)
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns true }
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
result.size shouldBe 1
assertNotNull(result[0].node)
result[0].node?.user?.id shouldBe "!1234"
}
@Test
fun `matchDiscoveredTcpNodes returns null node when no database`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!1234")
val nodeDb = mapOf(1 to node)
val entries = listOf(DeviceListEntry.Tcp("Mesh_1234", "t192.168.1.50"))
val resolved =
listOf(
DiscoveredService(
name = "Meshtastic",
hostAddress = "192.168.1.50",
port = 4403,
txt = mapOf("id" to "!1234".encodeToByteArray()),
),
)
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("t192.168.1.50") } returns false }
val result = matchDiscoveredTcpNodes(entries, nodeDb, resolved, databaseManager)
result.size shouldBe 1
assertNull(result[0].node)
}
@Test
fun `buildRecentTcpEntries filters out discovered addresses`() {
val recentAddresses = listOf(RecentAddress("t192.168.1.50", "NodeA"), RecentAddress("t192.168.1.51", "NodeB"))
val discoveredAddresses = setOf("t192.168.1.50")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = buildRecentTcpEntries(recentAddresses, discoveredAddresses, emptyMap(), databaseManager)
result.size shouldBe 1
result[0].name shouldBe "NodeB"
result[0].fullAddress shouldBe "t192.168.1.51"
}
@Test
fun `buildRecentTcpEntries matches node by suffix`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!test1234")
val recentAddresses = listOf(RecentAddress("tMeshtastic_1234", "Meshtastic_1234"))
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("tMeshtastic_1234") } returns true }
val result = buildRecentTcpEntries(recentAddresses, emptySet(), mapOf(1 to node), databaseManager)
result.size shouldBe 1
assertNotNull(result[0].node)
result[0].node?.user?.id shouldBe "!test1234"
}
@Test
fun `buildRecentTcpEntries results are sorted by name`() {
val recentAddresses = listOf(RecentAddress("t10.0.0.2", "Zebra"), RecentAddress("t10.0.0.1", "Alpha"))
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = buildRecentTcpEntries(recentAddresses, emptySet(), emptyMap(), databaseManager)
result[0].name shouldBe "Alpha"
result[1].name shouldBe "Zebra"
}
@Test
fun `findNodeByNameSuffix returns null when no database`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor(any()) } returns false }
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
assertNull(result)
}
@Test
fun `findNodeByNameSuffix matches by last underscore segment`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
val result = findNodeByNameSuffix("Device_1234", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
assertNotNull(result)
result.user.id shouldBe "!abcd1234"
}
@Test
fun `findNodeByNameSuffix returns null when suffix is too short`() {
val node = TestDataFactory.createTestNode(num = 1, userId = "!abcd1234")
val databaseManager = mock<DatabaseManager> { every { hasDatabaseFor("s/dev/ttyUSB0") } returns true }
val result = findNodeByNameSuffix("Device_ab", "s/dev/ttyUSB0", mapOf(1 to node), databaseManager)
// "ab" is only 2 chars, below the minimum SUFFIX_LENGTH of 4
assertNull(result)
}
}