Add Local Mesh Discovery feature

* Introduce a new `:feature:discovery` module for scanning mesh topology across multiple LoRa presets
* Add `DiscoveryScanEngine` to manage scan lifecycles, preset shifting, and packet collection
* Update database schema to version 39 with tables for discovery sessions, preset results, and discovered nodes
* Implement UI screens for scan configuration, real-time progress, and historical session management
* Add flavor-specific discovery maps (Google Maps and OSM) for visualizing node positions and topology
* Include algorithmic and AI-powered summary generation for analyzing LoRa preset performance
* Add report export functionality for Text and PDF formats
* Integrate discovery entry point into the settings screen and navigation graphs
This commit is contained in:
James Rich
2026-04-28 21:14:01 -05:00
parent c0d95d6ac4
commit f8c7cc02ea
67 changed files with 6406 additions and 2 deletions

View File

@@ -221,6 +221,7 @@ dependencies {
implementation(projects.feature.map)
implementation(projects.feature.node)
implementation(projects.feature.settings)
implementation(projects.feature.discovery)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.widget)

View File

@@ -0,0 +1,32 @@
/*
* 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.app.map.discovery
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.util.DiscoveryMapNode
/** Flavor-unified entry point for the discovery map. OSMDroid implementation. */
@Composable
fun DiscoveryMap(
userLatitude: Double,
userLongitude: Double,
nodes: List<DiscoveryMapNode>,
modifier: Modifier = Modifier,
) {
DiscoveryOsmMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
}

View File

@@ -0,0 +1,167 @@
/*
* 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.app.map.discovery
import android.graphics.Paint
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import org.meshtastic.app.map.addCopyright
import org.meshtastic.app.map.addScaleBarOverlay
import org.meshtastic.app.map.model.CustomTileSource
import org.meshtastic.app.map.rememberMapViewWithLifecycle
import org.meshtastic.app.map.zoomIn
import org.meshtastic.core.ui.theme.DiscoveryMapColors
import org.meshtastic.core.ui.util.DiscoveryMapNode
import org.meshtastic.core.ui.util.DiscoveryNeighborType
import org.osmdroid.util.BoundingBox
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.overlay.Marker
import org.osmdroid.views.overlay.Polyline
private const val SINGLE_POINT_ZOOM = 14.0
private const val ZOOM_OUT_LEVELS = 0.5
/**
* OSMDroid implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green =
* direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
*/
@Composable
fun DiscoveryOsmMap(
userLatitude: Double,
userLongitude: Double,
nodes: List<DiscoveryMapNode>,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val density = LocalDensity.current
val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
val userGeoPoint = remember(userLatitude, userLongitude) { GeoPoint(userLatitude, userLongitude) }
val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
// Build bounding box from all points
val allGeoPoints =
remember(validNodes, hasValidUserPosition) {
buildList {
if (hasValidUserPosition) add(userGeoPoint)
validNodes.forEach { add(GeoPoint(it.latitude, it.longitude)) }
}
}
val initialBounds =
remember(allGeoPoints) {
if (allGeoPoints.isEmpty()) BoundingBox() else BoundingBox.fromGeoPoints(allGeoPoints)
}
var hasCentered by remember { mutableStateOf(false) }
val mapView =
rememberMapViewWithLifecycle(
applicationId = context.packageName,
box = initialBounds,
tileSource = CustomTileSource.getTileSource(0),
)
// Camera auto-center once
LaunchedEffect(allGeoPoints) {
if (hasCentered || allGeoPoints.isEmpty()) return@LaunchedEffect
if (allGeoPoints.size == 1) {
mapView.controller.setCenter(allGeoPoints.first())
mapView.controller.setZoom(SINGLE_POINT_ZOOM)
} else {
mapView.zoomToBoundingBox(BoundingBox.fromGeoPoints(allGeoPoints).zoomIn(-ZOOM_OUT_LEVELS), true)
}
hasCentered = true
}
AndroidView(
modifier = modifier,
factory = { mapView.apply { setDestroyMode(false) } },
update = { map ->
map.overlays.clear()
map.addCopyright()
map.addScaleBarOverlay(density)
// User position marker
if (hasValidUserPosition) {
val userMarker =
Marker(map).apply {
position = userGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = "Your Position"
icon = context.getDrawable(android.R.drawable.ic_menu_mylocation)
}
map.overlays.add(userMarker)
}
// Node markers
validNodes.forEach { node ->
val nodeGeoPoint = GeoPoint(node.latitude, node.longitude)
val marker =
Marker(map).apply {
position = nodeGeoPoint
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
title = node.longName ?: node.shortName ?: "Unknown"
snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm"
val drawableId =
if (node.isSensorNode) {
org.meshtastic.app.R.drawable.ic_thermostat
} else {
org.meshtastic.app.R.drawable.ic_person
}
icon = context.getDrawable(drawableId)
// Default OSM marker handles color tinting via icon overlay or custom drawables if needed,
// but setting the icon directly overrides the default teardrop pin.
}
map.overlays.add(marker)
}
// Polylines from user to direct neighbors
if (hasValidUserPosition) {
validNodes
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
.forEach { node ->
val polyline =
Polyline().apply {
setPoints(listOf(userGeoPoint, GeoPoint(node.latitude, node.longitude)))
outlinePaint.apply {
color = DiscoveryMapColors.DirectLine.toArgb()
strokeWidth = with(density) { 3.dp.toPx() }
strokeCap = Paint.Cap.ROUND
style = Paint.Style.STROKE
}
}
map.overlays.add(polyline)
}
}
map.invalidate()
},
)
}

View File

@@ -0,0 +1,144 @@
/*
* 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.app.map.discovery
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import com.google.android.gms.maps.CameraUpdateFactory
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.android.gms.maps.model.LatLngBounds
import com.google.maps.android.compose.ComposeMapColorScheme
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MapsComposeExperimentalApi
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.Polyline
import com.google.maps.android.compose.rememberCameraPositionState
import com.google.maps.android.compose.rememberUpdatedMarkerState
import org.meshtastic.core.ui.util.DiscoveryMapNode
import org.meshtastic.core.ui.util.DiscoveryNeighborType
private const val DEFAULT_ZOOM = 12f
private const val BOUNDS_PADDING_PX = 100
private val DirectColor = Color(0xFF4CAF50)
private val MeshColor = Color(0xFF2196F3)
private val UserColor = Color(0xFFFF9800)
private val DirectLineColor = Color(0xFF4CAF50).copy(alpha = 0.5f)
/**
* Google Maps implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green
* = direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
*/
@OptIn(MapsComposeExperimentalApi::class)
@Composable
fun DiscoveryGoogleMap(
userLatitude: Double,
userLongitude: Double,
nodes: List<DiscoveryMapNode>,
modifier: Modifier = Modifier,
) {
val dark = isSystemInDarkTheme()
val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
val userLatLng = remember(userLatitude, userLongitude) { LatLng(userLatitude, userLongitude) }
val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
val cameraState = rememberCameraPositionState {
position =
CameraPosition.fromLatLngZoom(if (hasValidUserPosition) userLatLng else LatLng(0.0, 0.0), DEFAULT_ZOOM)
}
// Auto-fit bounds on first composition
LaunchedEffect(validNodes, hasValidUserPosition) {
val allPoints = buildList {
if (hasValidUserPosition) add(userLatLng)
validNodes.forEach { add(LatLng(it.latitude, it.longitude)) }
}
if (allPoints.size >= 2) {
val boundsBuilder = LatLngBounds.builder()
allPoints.forEach { boundsBuilder.include(it) }
cameraState.animate(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), BOUNDS_PADDING_PX))
} else if (allPoints.size == 1) {
cameraState.animate(CameraUpdateFactory.newLatLngZoom(allPoints.first(), DEFAULT_ZOOM))
}
}
GoogleMap(
mapColorScheme = mapColorScheme,
modifier = modifier,
uiSettings =
MapUiSettings(
zoomControlsEnabled = true,
mapToolbarEnabled = false,
compassEnabled = true,
myLocationButtonEnabled = false,
),
cameraPositionState = cameraState,
) {
// User position marker
if (hasValidUserPosition) {
MarkerComposable(state = rememberUpdatedMarkerState(position = userLatLng), title = "Your Position") {
DiscoveryMarkerChip(label = "You", color = UserColor)
}
}
// Node markers
validNodes.forEach { node ->
val nodeLatLng = LatLng(node.latitude, node.longitude)
val markerColor =
when (node.neighborType) {
DiscoveryNeighborType.DIRECT -> DirectColor
DiscoveryNeighborType.MESH -> MeshColor
}
val nodeIcon =
if (node.isSensorNode) {
org.meshtastic.core.ui.icon.MeshtasticIcons.Temperature
} else {
org.meshtastic.core.ui.icon.MeshtasticIcons.Person
}
MarkerComposable(
state = rememberUpdatedMarkerState(position = nodeLatLng),
title = node.longName ?: node.shortName ?: "Unknown",
snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm",
) {
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
}
}
// Polylines from user to direct neighbors
if (hasValidUserPosition) {
validNodes
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
.forEach { node ->
Polyline(
points = listOf(userLatLng, LatLng(node.latitude, node.longitude)),
color = DirectLineColor,
width = 4f,
)
}
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.app.map.discovery
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import org.meshtastic.core.ui.util.DiscoveryMapNode
/** Flavor-unified entry point for the discovery map. Google Maps implementation. */
@Composable
fun DiscoveryMap(
userLatitude: Double,
userLongitude: Double,
nodes: List<DiscoveryMapNode>,
modifier: Modifier = Modifier,
) {
DiscoveryGoogleMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
}

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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.app.map.discovery
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
/** Compact chip rendered as a Google Maps marker icon for discovery nodes. */
@Composable
fun DiscoveryMarkerChip(label: String, color: Color, modifier: Modifier = Modifier, icon: ImageVector? = null) {
Box(
modifier =
modifier
.background(color = color, shape = RoundedCornerShape(12.dp))
.border(width = 1.dp, color = Color.White, shape = RoundedCornerShape(12.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
if (icon != null) {
Icon(imageVector = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(16.dp))
} else {
Text(text = label, style = MaterialTheme.typography.labelSmall, color = Color.White)
}
}
}

View File

@@ -69,6 +69,7 @@ import org.meshtastic.core.ui.theme.MODE_DYNAMIC
import org.meshtastic.core.ui.util.LocalAnalyticsIntroProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerProvider
import org.meshtastic.core.ui.util.LocalBarcodeScannerSupported
import org.meshtastic.core.ui.util.LocalDiscoveryMapProvider
import org.meshtastic.core.ui.util.LocalEventBranding
import org.meshtastic.core.ui.util.LocalInlineMapProvider
import org.meshtastic.core.ui.util.LocalMapMainScreenProvider
@@ -180,6 +181,7 @@ class MainActivity : AppCompatActivity() {
@Suppress("LongMethod")
@Composable
@Suppress("LongMethod")
private fun AppCompositionLocals(content: @Composable () -> Unit) {
val eventEdition by model.eventEdition.collectAsStateWithLifecycle()
CompositionLocalProvider(
@@ -211,6 +213,10 @@ class MainActivity : AppCompatActivity() {
modifier = modifier,
)
},
LocalDiscoveryMapProvider provides
{ userLat, userLon, nodes, modifier ->
org.meshtastic.app.map.discovery.DiscoveryMap(userLat, userLon, nodes, modifier)
},
LocalNodeMapScreenProvider provides
{ destNum, onNavigateUp ->
val vm = koinViewModel<NodeMapViewModel>()

View File

@@ -47,6 +47,7 @@ import org.meshtastic.core.service.di.CoreServiceModule
import org.meshtastic.core.takserver.di.CoreTakServerModule
import org.meshtastic.core.ui.di.CoreUiModule
import org.meshtastic.feature.connections.di.FeatureConnectionsModule
import org.meshtastic.feature.discovery.di.FeatureDiscoveryModule
import org.meshtastic.feature.firmware.di.FeatureFirmwareModule
import org.meshtastic.feature.intro.di.FeatureIntroModule
import org.meshtastic.feature.map.di.FeatureMapModule
@@ -85,6 +86,7 @@ import org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule
FeatureConnectionsModule::class,
FeatureMapModule::class,
FeatureSettingsModule::class,
FeatureDiscoveryModule::class,
FeatureFirmwareModule::class,
FeatureIntroModule::class,
FeatureWidgetModule::class,

View File

@@ -43,6 +43,7 @@ import org.meshtastic.core.ui.component.MeshtasticNavDisplay
import org.meshtastic.core.ui.component.MeshtasticNavigationSuite
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -87,6 +88,7 @@ fun MainScreen() {
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
discoveryGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
wifiProvisionGraph(backStack)

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M480,480Q414,480 367,433Q320,386 320,320Q320,254 367,207Q414,160 480,160Q546,160 593,207Q640,254 640,320Q640,386 593,433Q546,480 480,480ZM160,720L160,688Q160,654 177.5,625.5Q195,597 224,582Q286,551 350,535.5Q414,520 480,520Q546,520 610,535.5Q674,551 736,582Q765,597 782.5,625.5Q800,654 800,688L800,720Q800,753 776.5,776.5Q753,800 720,800L240,800Q207,800 183.5,776.5Q160,753 160,720Z"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M560,440Q543,440 531.5,428.5Q520,417 520,400Q520,383 531.5,371.5Q543,360 560,360L680,360Q697,360 708.5,371.5Q720,383 720,400Q720,417 708.5,428.5Q697,440 680,440L560,440ZM560,280Q543,280 531.5,268.5Q520,257 520,240Q520,223 531.5,211.5Q543,200 560,200L800,200Q817,200 828.5,211.5Q840,223 840,240Q840,257 828.5,268.5Q817,280 800,280L560,280ZM320,840Q237,840 178.5,781.5Q120,723 120,640Q120,592 141,550.5Q162,509 200,480L200,240Q200,190 235,155Q270,120 320,120Q370,120 405,155Q440,190 440,240L440,480Q478,509 499,550.5Q520,592 520,640Q520,723 461.5,781.5Q403,840 320,840ZM200,640L440,640Q440,611 427.5,586Q415,561 392,544L360,520L360,240Q360,223 348.5,211.5Q337,200 320,200Q303,200 291.5,211.5Q280,223 280,240L280,520L248,544Q225,561 212.5,586Q200,611 200,640Z"/>
</vector>

View File

@@ -26,6 +26,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.meshtastic.core.navigation.NodesRoute
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -50,6 +51,7 @@ class NavigationAssemblyTest {
mapGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
discoveryGraph(backStack)
settingsGraph(backStack)
firmwareGraph(backStack)
}

View File

@@ -0,0 +1,26 @@
/*
* 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.core.data.manager
import org.koin.core.annotation.Single
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
@Single
class DiscoveryPacketCollectorRegistryImpl : DiscoveryPacketCollectorRegistry {
override var collector: DiscoveryPacketCollector? = null
}

View File

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

View File

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ import androidx.room3.TypeConverters
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.DiscoveryDao
import org.meshtastic.core.database.dao.FirmwareReleaseDao
import org.meshtastic.core.database.dao.MeshLogDao
import org.meshtastic.core.database.dao.NodeInfoDao
@@ -33,6 +34,9 @@ import org.meshtastic.core.database.dao.QuickChatActionDao
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.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
@@ -57,6 +61,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
DeviceHardwareEntity::class,
FirmwareReleaseEntity::class,
TracerouteNodePositionEntity::class,
DiscoverySessionEntity::class,
DiscoveryPresetResultEntity::class,
DiscoveredNodeEntity::class,
],
autoMigrations =
[
@@ -95,8 +102,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
AutoMigration(from = 35, to = 36),
AutoMigration(from = 36, to = 37),
AutoMigration(from = 37, to = 38),
AutoMigration(from = 38, to = 39),
],
version = 38,
version = 39,
exportSchema = true,
)
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
@@ -117,6 +125,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,108 @@
/*
* 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.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)
// 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
@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,53 @@
/*
* 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.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,
)

View File

@@ -0,0 +1,62 @@
/*
* 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.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 = "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) 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.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

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

View File

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

View File

@@ -368,7 +368,7 @@ class BleRadioTransport(
Logger.d { "[$address] Requested high BLE connection priority" }
// Wait for the connection parameter update to succeed before starting the heavy traffic
// in onConnect(). Otherwise, the Android BLE stack may disconnect with GATT 147.
delay(1.seconds)
delay(2.seconds)
}
this@BleRadioTransport.callback.onConnect()

View File

@@ -0,0 +1,38 @@
/*
* 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.core.repository
import org.meshtastic.core.model.DataPacket
import org.meshtastic.proto.MeshPacket
/**
* Interface for collecting packets during an active discovery scan. The scan engine implements this interface and
* registers/unregisters with the packet handler to receive packets during dwell windows.
*/
interface DiscoveryPacketCollector {
/** Whether this collector is currently active (scan in progress). */
val isActive: Boolean
/**
* Called when a mesh packet is received during an active scan. Implementations should classify and aggregate the
* packet data.
*
* @param meshPacket The raw mesh packet from the radio
* @param dataPacket The decoded data packet with routing info
*/
suspend fun onPacketReceived(meshPacket: MeshPacket, dataPacket: DataPacket)
}

View File

@@ -0,0 +1,26 @@
/*
* Copyright (c) 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.core.repository
/**
* Registry for discovery packet collectors. The scan engine registers itself when a scan starts and unregisters when it
* stops. The packet handler checks for an active collector and forwards packets to it.
*/
interface DiscoveryPacketCollectorRegistry {
/** The currently registered collector, or null if no scan is active. */
var collector: DiscoveryPacketCollector?
}

View File

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

View File

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

View File

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

View File

@@ -268,6 +268,7 @@ dependencies {
implementation(projects.feature.messaging)
implementation(projects.feature.connections)
implementation(projects.feature.map)
implementation(projects.feature.discovery)
implementation(projects.feature.firmware)
implementation(projects.feature.wifiProvision)
implementation(projects.feature.intro)

View File

@@ -96,6 +96,7 @@ import org.meshtastic.core.takserver.di.module as coreTakServerModule
import org.meshtastic.core.ui.di.module as coreUiModule
import org.meshtastic.desktop.di.module as desktopDiModule
import org.meshtastic.feature.connections.di.module as featureConnectionsModule
import org.meshtastic.feature.discovery.di.module as featureDiscoveryModule
import org.meshtastic.feature.firmware.di.module as featureFirmwareModule
import org.meshtastic.feature.intro.di.module as featureIntroModule
import org.meshtastic.feature.map.di.module as featureMapModule
@@ -136,6 +137,7 @@ fun desktopModule() = module {
org.meshtastic.feature.messaging.di.FeatureMessagingModule().featureMessagingModule(),
org.meshtastic.feature.connections.di.FeatureConnectionsModule().featureConnectionsModule(),
org.meshtastic.feature.map.di.FeatureMapModule().featureMapModule(),
org.meshtastic.feature.discovery.di.FeatureDiscoveryModule().featureDiscoveryModule(),
org.meshtastic.feature.firmware.di.FeatureFirmwareModule().featureFirmwareModule(),
org.meshtastic.feature.intro.di.FeatureIntroModule().featureIntroModule(),
org.meshtastic.feature.wifiprovision.di.FeatureWifiProvisionModule().featureWifiProvisionModule(),

View File

@@ -23,6 +23,7 @@ import org.meshtastic.core.navigation.MultiBackstack
import org.meshtastic.core.navigation.TopLevelDestination
import org.meshtastic.core.ui.viewmodel.UIViewModel
import org.meshtastic.feature.connections.navigation.connectionsGraph
import org.meshtastic.feature.discovery.navigation.discoveryGraph
import org.meshtastic.feature.firmware.navigation.firmwareGraph
import org.meshtastic.feature.map.navigation.mapGraph
import org.meshtastic.feature.messaging.navigation.contactsGraph
@@ -54,5 +55,6 @@ fun EntryProviderScope<NavKey>.desktopNavGraph(
settingsGraph(backStack)
channelsGraph(backStack)
connectionsGraph(backStack)
discoveryGraph(backStack)
wifiProvisionGraph(backStack)
}

View File

@@ -0,0 +1,55 @@
/*
* 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/>.
*/
plugins {
alias(libs.plugins.meshtastic.kmp.feature)
alias(libs.plugins.meshtastic.kotlinx.serialization)
}
kotlin {
jvm()
@Suppress("UnstableApiUsage")
android {
namespace = "org.meshtastic.feature.discovery"
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
}
sourceSets {
commonMain.dependencies {
implementation(libs.jetbrains.navigation3.ui)
implementation(projects.core.common)
implementation(projects.core.data)
implementation(projects.core.database)
implementation(projects.core.di)
implementation(projects.core.model)
implementation(projects.core.navigation)
implementation(projects.core.network)
implementation(projects.core.prefs)
implementation(projects.core.proto)
implementation(projects.core.repository)
implementation(projects.core.resources)
implementation(projects.core.service)
implementation(projects.core.ui)
implementation(libs.kotlinx.collections.immutable)
}
commonTest.dependencies { implementation(projects.core.testing) }
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.discovery.ai
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
// TODO: Replace with real Gemini Nano on-device implementation once
// `com.google.ai.edge:aicore` or `com.google.android.gms:play-services-generativeai`
// is added to libs.versions.toml. The implementation should:
// 1. Check model availability via GenerativeModel.isAvailable()
// 2. Build a structured prompt with session metrics (nodes, utilization, presets)
// 3. Call generateContent() with the prompt
// 4. Fall back to the algorithmic generator on any error
/**
* Android provider that will use Gemini Nano for on-device AI summaries.
*
* Currently delegates to [DiscoverySummaryGenerator] because the Gemini Nano SDK dependency is not yet in the version
* catalog.
*/
@Single(binds = [DiscoverySummaryAiProvider::class])
class GeminiNanoSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider {
// Delegates to DiscoverySummaryGenerator (algorithmic) so results are always available.
// When real Gemini Nano SDK is wired, this should check GenerativeModel.isAvailable() at runtime.
override val isAvailable: Boolean = true
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String = generator.generateSessionSummary(session, presetResults)
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String =
generator.generatePresetSummary(result)
}

View File

@@ -0,0 +1,230 @@
/*
* 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.discovery.export
import android.graphics.Paint
import android.graphics.pdf.PdfDocument
import kotlinx.coroutines.withContext
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import java.io.ByteArrayOutputStream
private const val PAGE_WIDTH = 612
private const val PAGE_HEIGHT = 792
private const val MARGIN_LEFT = 40f
private const val MARGIN_TOP = 50f
private const val LINE_HEIGHT = 18f
private const val SECTION_GAP = 12f
private const val TITLE_SIZE = 18f
private const val HEADING_SIZE = 14f
private const val BODY_SIZE = 10f
private const val LABEL_SIZE = 9f
private const val FOOTER_SIZE = 8f
private const val PAGE_BOTTOM_MARGIN = 60f
private const val LABEL_COLUMN_WIDTH = 160f
@Single
class PdfDiscoveryExporter : DiscoveryExporter {
override suspend fun export(data: DiscoveryExportData): ExportResult = withContext(ioDispatcher) {
@Suppress("TooGenericExceptionCaught")
try {
val bytes = renderPdf(data)
val fileName = DiscoveryReportFormatter.generateFileName(data.session, "pdf")
ExportResult.Success(content = bytes, mimeType = "application/pdf", fileName = fileName)
} catch (e: Exception) {
ExportResult.Error("PDF generation failed: ${e.message}")
}
}
private fun renderPdf(data: DiscoveryExportData): ByteArray {
val document = PdfDocument()
val renderer = PageRenderer(document)
renderer.drawTitle("Meshtastic Discovery Report")
renderer.advanceLine()
// Session overview
renderer.drawHeading("Session Overview")
for ((label, value) in DiscoveryReportFormatter.formatSessionOverviewLines(data.session)) {
renderer.drawLabelValue(label, value)
}
renderer.advanceSection()
// Per-preset sections
for (result in data.presetResults) {
renderer.drawHeading("Preset: ${result.presetName}")
for ((label, value) in DiscoveryReportFormatter.formatPresetLines(result)) {
renderer.drawLabelValue(label, value)
}
val nodes = data.nodesByPreset[result.id].orEmpty()
if (nodes.isNotEmpty()) {
renderer.advanceLine()
renderer.drawSubheading("Discovered Nodes (${nodes.size})")
for (node in nodes) {
renderer.drawBody(DiscoveryReportFormatter.formatNodeLine(node))
}
}
renderer.advanceSection()
}
// AI summary
val summary = data.session.aiSummary
if (!summary.isNullOrBlank()) {
renderer.drawHeading("AI Analysis")
renderer.drawWrappedBody(summary)
renderer.advanceSection()
}
renderer.drawFooter("Generated by Meshtastic Android")
renderer.finishCurrentPage()
val outputStream = ByteArrayOutputStream()
document.writeTo(outputStream)
document.close()
return outputStream.toByteArray()
}
@Suppress("TooManyFunctions")
private class PageRenderer(private val document: PdfDocument) {
private var pageNumber = 0
private var currentPage: PdfDocument.Page? = null
private var yPosition = MARGIN_TOP
private val titlePaint =
Paint().apply {
textSize = TITLE_SIZE
isFakeBoldText = true
isAntiAlias = true
}
private val headingPaint =
Paint().apply {
textSize = HEADING_SIZE
isFakeBoldText = true
isAntiAlias = true
}
private val bodyPaint =
Paint().apply {
textSize = BODY_SIZE
isAntiAlias = true
}
private val labelPaint =
Paint().apply {
textSize = LABEL_SIZE
isAntiAlias = true
color = android.graphics.Color.DKGRAY
}
private val footerPaint =
Paint().apply {
textSize = FOOTER_SIZE
isAntiAlias = true
color = android.graphics.Color.GRAY
}
private fun ensurePage() {
if (currentPage == null) {
pageNumber++
val pageInfo = PdfDocument.PageInfo.Builder(PAGE_WIDTH, PAGE_HEIGHT, pageNumber).create()
currentPage = document.startPage(pageInfo)
yPosition = MARGIN_TOP
}
}
private fun checkPageBreak(linesNeeded: Int = 1) {
if (yPosition + linesNeeded * LINE_HEIGHT > PAGE_HEIGHT - PAGE_BOTTOM_MARGIN) {
finishCurrentPage()
ensurePage()
}
}
fun finishCurrentPage() {
currentPage?.let { document.finishPage(it) }
currentPage = null
}
fun drawTitle(text: String) {
ensurePage()
currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, titlePaint)
yPosition += LINE_HEIGHT + SECTION_GAP
}
fun drawHeading(text: String) {
checkPageBreak(linesNeeded = 2)
ensurePage()
currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, headingPaint)
yPosition += LINE_HEIGHT
}
fun drawSubheading(text: String) {
checkPageBreak()
ensurePage()
currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, bodyPaint.apply { isFakeBoldText = true })
bodyPaint.isFakeBoldText = false
yPosition += LINE_HEIGHT
}
fun drawBody(text: String) {
checkPageBreak()
ensurePage()
currentPage?.canvas?.drawText(text, MARGIN_LEFT, yPosition, bodyPaint)
yPosition += LINE_HEIGHT
}
fun drawLabelValue(label: String, value: String) {
checkPageBreak()
ensurePage()
currentPage?.canvas?.let { canvas ->
canvas.drawText("$label:", MARGIN_LEFT, yPosition, labelPaint)
canvas.drawText(value, MARGIN_LEFT + LABEL_COLUMN_WIDTH, yPosition, bodyPaint)
}
yPosition += LINE_HEIGHT
}
fun drawWrappedBody(text: String) {
val maxWidth = PAGE_WIDTH - MARGIN_LEFT * 2
val words = text.split(" ")
var currentLine = StringBuilder()
for (word in words) {
val testLine = if (currentLine.isEmpty()) word else "$currentLine $word"
if (bodyPaint.measureText(testLine) > maxWidth && currentLine.isNotEmpty()) {
drawBody(currentLine.toString())
currentLine = StringBuilder(word)
} else {
currentLine = StringBuilder(testLine)
}
}
if (currentLine.isNotEmpty()) {
drawBody(currentLine.toString())
}
}
fun drawFooter(text: String) {
ensurePage()
currentPage?.canvas?.drawText(text, MARGIN_LEFT, PAGE_HEIGHT - MARGIN_TOP / 2, footerPaint)
}
fun advanceLine() {
yPosition += LINE_HEIGHT
}
fun advanceSection() {
yPosition += SECTION_GAP
}
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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.discovery
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.database.dao.DiscoveryDao
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.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@KoinViewModel
class DiscoveryHistoryDetailViewModel(
@InjectedParam private val sessionId: Long,
private val discoveryDao: DiscoveryDao,
) : ViewModel() {
val session: StateFlow<DiscoverySessionEntity?> =
discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null)
val presetResults: StateFlow<List<DiscoveryPresetResultEntity>> =
discoveryDao.getPresetResultsFlow(sessionId).stateInWhileSubscribed(initialValue = emptyList())
private val _nodesByPreset = MutableStateFlow<Map<Long, List<DiscoveredNodeEntity>>>(emptyMap())
val nodesByPreset: StateFlow<Map<Long, List<DiscoveredNodeEntity>>> = _nodesByPreset.asStateFlow()
init {
loadNodes()
}
private fun loadNodes() {
safeLaunch(tag = "loadNodes") {
val results = discoveryDao.getPresetResults(sessionId)
val nodesMap = mutableMapOf<Long, List<DiscoveredNodeEntity>>()
for (result in results) {
nodesMap[result.id] = discoveryDao.getDiscoveredNodes(result.id)
}
_nodesByPreset.value = nodesMap
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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.discovery
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.StateFlow
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@KoinViewModel
class DiscoveryHistoryViewModel(private val discoveryDao: DiscoveryDao) : ViewModel() {
val sessions: StateFlow<List<DiscoverySessionEntity>> =
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
fun deleteSession(sessionId: Long) {
safeLaunch(tag = "deleteSession") { discoveryDao.deleteSession(sessionId) }
}
}

View File

@@ -0,0 +1,55 @@
/*
* 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.discovery
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@KoinViewModel
class DiscoveryMapViewModel(@InjectedParam private val sessionId: Long, private val discoveryDao: DiscoveryDao) :
ViewModel() {
val session: StateFlow<DiscoverySessionEntity?> =
discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null)
private val _allNodes = MutableStateFlow<List<DiscoveredNodeEntity>>(emptyList())
val allNodes: StateFlow<List<DiscoveredNodeEntity>> = _allNodes.asStateFlow()
init {
loadAllNodes()
}
private fun loadAllNodes() {
safeLaunch(tag = "loadAllNodes") {
val results = discoveryDao.getPresetResults(sessionId)
val nodes = results.flatMap { discoveryDao.getDiscoveredNodes(it.id) }
// Deduplicate by nodeNum — keep the entry with strongest signal
val deduped =
nodes.groupBy { it.nodeNum }.values.map { dupes -> dupes.maxByOrNull { it.snr } ?: dupes.first() }
_allNodes.value = deduped
}
}
}

View File

@@ -0,0 +1,550 @@
/*
* 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.discovery
import co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.ioDispatcher
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.database.dao.DiscoveryDao
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.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.model.util.decodeOrNull
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.proto.Config
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.NeighborInfo
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
/**
* Core scan engine for Local Mesh Discovery.
*
* Cycles through a queue of LoRa presets, dwells on each for a configured duration while collecting packets, then
* persists aggregated results via [DiscoveryDao].
*/
@Single
@Suppress("LongParameterList")
class DiscoveryScanEngine(
private val radioController: RadioController,
private val serviceRepository: ServiceRepository,
private val nodeRepository: NodeRepository,
private val radioConfigRepository: RadioConfigRepository,
private val collectorRegistry: DiscoveryPacketCollectorRegistry,
private val discoveryDao: DiscoveryDao,
private val aiProvider: DiscoverySummaryAiProvider,
) : DiscoveryPacketCollector {
// region Public state
private val _scanState = MutableStateFlow<DiscoveryScanState>(DiscoveryScanState.Idle)
val scanState: StateFlow<DiscoveryScanState> = _scanState.asStateFlow()
private val _currentSession = MutableStateFlow<DiscoverySessionEntity?>(null)
val currentSession: StateFlow<DiscoverySessionEntity?> = _currentSession.asStateFlow()
override val isActive: Boolean
get() = _scanState.value !is DiscoveryScanState.Idle && _scanState.value !is DiscoveryScanState.Complete
// endregion
// region Internal scan state
private val mutex = Mutex()
private var scanScope: CoroutineScope? = null
private var dwellJob: Job? = null
private var homePreset: ChannelOption? = null
private var sessionId: Long = 0
/** Nodes collected for the current preset dwell. Keyed by nodeNum. */
private val collectedNodes = mutableMapOf<Long, CollectedNodeData>()
/** DeviceMetrics entries per node for the 2-packet rule. Keyed by nodeNum. */
private val deviceMetricsLog = mutableMapOf<Long, MutableList<DeviceMetricsEntry>>()
private var currentPresetName: String = ""
private var totalDwellSeconds: Long = 0
// endregion
// region Internal data classes
private data class CollectedNodeData(
var nodeNum: Long,
var shortName: String? = null,
var longName: String? = null,
var neighborType: String = "direct",
var latitude: Double? = null,
var longitude: Double? = null,
var snr: Float = 0f,
var rssi: Int = 0,
var hopCount: Int = 0,
var messageCount: Int = 0,
var sensorPacketCount: Int = 0,
)
private data class DeviceMetricsEntry(val timestamp: Long, val channelUtil: Double, val airUtilTx: Double)
// endregion
// region Public API
/**
* Starts a discovery scan across the given [presets].
*
* @param presets The LoRa presets to cycle through.
* @param dwellDurationSeconds How long to listen on each preset.
*/
suspend fun startScan(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
require(presets.isNotEmpty()) { "At least one preset is required" }
require(dwellDurationSeconds > 0) { "Dwell duration must be positive" }
mutex.withLock {
if (isActive) {
Logger.w { "DiscoveryScanEngine: scan already active, ignoring startScan" }
return
}
// Capture the current LoRa preset as "home"
homePreset =
radioConfigRepository.localConfigFlow.first().lora?.modem_preset?.let { modemPreset ->
ChannelOption.entries.firstOrNull { it.modemPreset == modemPreset }
} ?: ChannelOption.DEFAULT
val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum
val myPosition = myNodeNum?.let { nodeRepository.nodeDBbyNum.value[it]?.position }
val latDouble = (myPosition?.latitude_i ?: 0).toDouble() / POSITION_DIVISOR
val lonDouble = (myPosition?.longitude_i ?: 0).toDouble() / POSITION_DIVISOR
// Create the DB session
val session =
DiscoverySessionEntity(
timestamp = nowMillis,
presetsScanned = presets.joinToString(",") { it.name },
homePreset = homePreset?.name ?: ChannelOption.DEFAULT.name,
completionStatus = "in_progress",
userLatitude = latDouble,
userLongitude = lonDouble,
)
sessionId = discoveryDao.insertSession(session)
_currentSession.value = session.copy(id = sessionId)
// Register as packet collector
collectorRegistry.collector = this
// Set initial state so the scan loop's isActive guard succeeds
_scanState.value = DiscoveryScanState.Shifting(presets.first().name)
currentPresetName = presets.first().name
totalDwellSeconds = dwellDurationSeconds
// Launch scan coroutine
val scope = CoroutineScope(ioDispatcher + SupervisorJob())
scanScope = scope
scope.launch { runScanLoop(presets, dwellDurationSeconds) }
}
}
/** Stops the active scan and restores the home preset. */
suspend fun stopScan() {
mutex.withLock {
if (!isActive) return
Logger.i { "DiscoveryScanEngine: stopping scan" }
cancelScanInternal()
}
persistCurrentDwellResults()
finalizeSession("stopped")
_scanState.value = DiscoveryScanState.Idle
// Restore home preset in the background so we don't block the UI with the connection wait
CoroutineScope(Dispatchers.Default).launch { restoreHomePreset() }
}
/** Resets engine state after the UI has acknowledged completion. */
fun reset() {
_scanState.value = DiscoveryScanState.Idle
_currentSession.value = null
}
// endregion
// region DiscoveryPacketCollector
@Suppress("CyclomaticComplexMethod", "ComplexCondition")
override suspend fun onPacketReceived(meshPacket: MeshPacket, dataPacket: DataPacket) {
if (_scanState.value !is DiscoveryScanState.Dwell) return
val fromNum = meshPacket.from.toLong()
val portNum = meshPacket.decoded?.portnum ?: return
mutex.withLock {
val node = collectedNodes.getOrPut(fromNum) { CollectedNodeData(nodeNum = fromNum) }
// Update signal info from the direct packet
if (meshPacket.rx_snr != 0f) node.snr = meshPacket.rx_snr
if (meshPacket.rx_rssi != 0) node.rssi = meshPacket.rx_rssi
node.hopCount = dataPacket.hopsAway.coerceAtLeast(0)
when (portNum) {
PortNum.TEXT_MESSAGE_APP -> node.messageCount++
PortNum.POSITION_APP -> handlePosition(meshPacket, node)
PortNum.TELEMETRY_APP -> handleTelemetry(meshPacket, node, fromNum)
PortNum.NEIGHBORINFO_APP -> handleNeighborInfo(meshPacket)
else -> {
/* Other portnums don't need special handling */
}
}
// Ensure all nodes in the collection have names and position if available in the NodeDB
collectedNodes.values.forEach { n ->
val dbNode = nodeRepository.nodeDBbyNum.value[n.nodeNum.toInt()]
if (dbNode != null) {
if (n.shortName == null || n.longName == null) {
n.shortName = dbNode.user.short_name.ifBlank { null }
n.longName = dbNode.user.long_name.ifBlank { null }
}
if (n.latitude == null || n.longitude == null || (n.latitude == 0.0 && n.longitude == 0.0)) {
val dbLat = dbNode.position.latitude_i
val dbLon = dbNode.position.longitude_i
if (dbLat != null && dbLat != 0) n.latitude = dbLat.toDouble() / POSITION_DIVISOR
if (dbLon != null && dbLon != 0) n.longitude = dbLon.toDouble() / POSITION_DIVISOR
}
}
}
}
}
// endregion
// region Scan loop
@Suppress("ReturnCount")
private suspend fun runScanLoop(presets: List<ChannelOption>, dwellDurationSeconds: Long) {
for (preset in presets) {
if (!isActive) return
currentPresetName = preset.name
mutex.withLock {
collectedNodes.clear()
deviceMetricsLog.clear()
}
totalDwellSeconds = dwellDurationSeconds
// Shift to the new preset
_scanState.value = DiscoveryScanState.Shifting(preset.name)
shiftPreset(preset)
// Wait for reconnection
_scanState.value = DiscoveryScanState.Reconnecting(preset.name)
val reconnected = waitForConnection()
if (!reconnected) {
cancelScanInternal()
restoreHomePreset()
finalizeSession("paused")
_scanState.value = DiscoveryScanState.Idle
return
}
// Dwell
val dwellCompleted = runDwell(preset.name, dwellDurationSeconds)
if (!dwellCompleted) {
cancelScanInternal()
restoreHomePreset()
finalizeSession("paused")
_scanState.value = DiscoveryScanState.Idle
return
}
if (!isActive) return
// Persist this preset's results
persistCurrentDwellResults()
}
// All presets scanned
_scanState.value = DiscoveryScanState.Analysis
restoreHomePreset()
generateAiSummaries()
finalizeSession("complete")
_scanState.value = DiscoveryScanState.Complete
}
private suspend fun shiftPreset(preset: ChannelOption) {
val loraConfig = Config.LoRaConfig(use_preset = true, modem_preset = preset.modemPreset)
val config = Config(lora = loraConfig)
radioController.setLocalConfig(config)
Logger.i { "DiscoveryScanEngine: shifted to ${preset.name} (use_preset=true)" }
// The firmware often restarts the radio or reboots after a LoRa config change.
// Wait a short moment to ensure we don't consider it 'connected' right before it drops.
delay(3000)
}
private suspend fun waitForConnection(): Boolean {
val result =
withTimeoutOrNull(RECONNECT_TIMEOUT_MS) {
serviceRepository.connectionState.first { it is ConnectionState.Connected }
}
return result != null
}
private suspend fun runDwell(presetName: String, durationSeconds: Long): Boolean {
var remaining = durationSeconds
while (remaining > 0 && isActive) {
val isConnected = serviceRepository.connectionState.value is ConnectionState.Connected
if (!isConnected) {
_scanState.value = DiscoveryScanState.Reconnecting(presetName)
val reconnected = waitForConnection()
if (!reconnected) return false
continue
}
_scanState.value =
DiscoveryScanState.Dwell(
presetName = presetName,
remainingSeconds = remaining,
totalSeconds = durationSeconds,
)
delay(TICK_INTERVAL_MS)
remaining--
}
return true
}
// endregion
// region Packet handlers
private fun handlePosition(meshPacket: MeshPacket, node: CollectedNodeData) {
val payload = meshPacket.decoded?.payload ?: return
val pos = Position.ADAPTER.decodeOrNull(payload, Logger) ?: return
val lat = pos.latitude_i
val lon = pos.longitude_i
if (lat != null && lat != 0) node.latitude = lat / POSITION_DIVISOR
if (lon != null && lon != 0) node.longitude = lon / POSITION_DIVISOR
}
private fun handleTelemetry(meshPacket: MeshPacket, node: CollectedNodeData, fromNum: Long) {
val payload = meshPacket.decoded?.payload ?: return
val telemetry = Telemetry.ADAPTER.decodeOrNull(payload, Logger) ?: return
val deviceMetrics = telemetry.device_metrics
if (deviceMetrics != null) {
val entries = deviceMetricsLog.getOrPut(fromNum) { mutableListOf() }
entries.add(
DeviceMetricsEntry(
timestamp = nowMillis,
channelUtil = deviceMetrics.channel_utilization?.toDouble() ?: 0.0,
airUtilTx = deviceMetrics.air_util_tx?.toDouble() ?: 0.0,
),
)
}
if (telemetry.environment_metrics != null) {
node.sensorPacketCount++
}
}
private fun handleNeighborInfo(meshPacket: MeshPacket) {
val payload = meshPacket.decoded?.payload ?: return
val ni = NeighborInfo.ADAPTER.decodeOrNull(payload, Logger) ?: return
for (neighbor in ni.neighbors) {
val neighborNum = neighbor.node_id.toLong()
val node =
collectedNodes.getOrPut(neighborNum) { CollectedNodeData(nodeNum = neighborNum, neighborType = "mesh") }
// Only mark as mesh if not already seen directly
if (node.snr == 0f && node.rssi == 0) {
node.neighborType = "mesh"
}
}
}
// endregion
// region Persistence
@Suppress("ReturnCount")
private suspend fun generateAiSummaries() {
if (sessionId == 0L || !aiProvider.isAvailable) return
val session = discoveryDao.getSession(sessionId) ?: return
val presetResults = discoveryDao.getPresetResults(sessionId)
if (presetResults.isEmpty()) return
// Generate per-preset AI summaries
for (result in presetResults) {
val presetSummary = aiProvider.generatePresetSummary(result)
if (presetSummary != null) {
discoveryDao.updatePresetResult(result.copy(aiSummary = presetSummary))
}
}
// Generate session-level AI summary
val sessionSummary = aiProvider.generateSessionSummary(session, presetResults)
if (sessionSummary != null) {
discoveryDao.updateSession(session.copy(aiSummary = sessionSummary))
}
}
private suspend fun persistCurrentDwellResults() {
if (sessionId == 0L) return
mutex.withLock {
if (collectedNodes.isEmpty()) {
// Persist a zero-result entry so the preset appears in reports
val emptyResult =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = currentPresetName,
dwellDurationSeconds = totalDwellSeconds,
)
discoveryDao.insertPresetResult(emptyResult)
return
}
val (avgChannelUtil, avgAirUtil) = computeAverageMetrics()
val directCount = collectedNodes.values.count { it.neighborType == "direct" }
val meshCount = collectedNodes.values.count { it.neighborType == "mesh" }
val presetResult =
DiscoveryPresetResultEntity(
sessionId = sessionId,
presetName = currentPresetName,
dwellDurationSeconds = totalDwellSeconds,
uniqueNodes = collectedNodes.size,
directNeighborCount = directCount,
meshNeighborCount = meshCount,
messageCount = collectedNodes.values.sumOf { it.messageCount },
sensorPacketCount = collectedNodes.values.sumOf { it.sensorPacketCount },
avgChannelUtilization = avgChannelUtil,
avgAirtimeRate = avgAirUtil,
)
val presetResultId = discoveryDao.insertPresetResult(presetResult)
val nodeEntities =
collectedNodes.values.map { data ->
DiscoveredNodeEntity(
presetResultId = presetResultId,
nodeNum = data.nodeNum,
shortName = data.shortName,
longName = data.longName,
neighborType = data.neighborType,
latitude = data.latitude,
longitude = data.longitude,
hopCount = data.hopCount,
snr = data.snr,
rssi = data.rssi,
messageCount = data.messageCount,
sensorPacketCount = data.sensorPacketCount,
)
}
discoveryDao.insertDiscoveredNodes(nodeEntities)
}
}
/**
* Computes average channel utilization and airtime from DeviceMetrics, applying the 2-packet rule (only nodes with
* ≥2 reports count).
*/
private fun computeAverageMetrics(): Pair<Double, Double> {
val qualifiedEntries = deviceMetricsLog.values.filter { it.size >= MIN_DEVICE_METRICS_PACKETS }
if (qualifiedEntries.isEmpty()) return 0.0 to 0.0
val avgChannel = qualifiedEntries.map { entries -> entries.map { it.channelUtil }.average() }.average()
val avgAir = qualifiedEntries.map { entries -> entries.map { it.airUtilTx }.average() }.average()
return avgChannel to avgAir
}
private suspend fun finalizeSession(status: String) {
if (sessionId == 0L) return
val uniqueCount = discoveryDao.getUniqueNodeCount(sessionId)
val presetResults = discoveryDao.getPresetResults(sessionId)
val session = discoveryDao.getSession(sessionId) ?: return
val totalDwell = presetResults.sumOf { it.dwellDurationSeconds }
val totalMsgs = presetResults.sumOf { it.messageCount }
val totalSensor = presetResults.sumOf { it.sensorPacketCount }
val avgChanUtil =
presetResults
.filter { it.uniqueNodes > 0 }
.map { it.avgChannelUtilization }
.average()
.takeIf { !it.isNaN() } ?: 0.0
discoveryDao.updateSession(
session.copy(
totalUniqueNodes = uniqueCount,
totalDwellSeconds = totalDwell,
totalMessages = totalMsgs,
totalSensorPackets = totalSensor,
avgChannelUtilization = avgChanUtil,
completionStatus = status,
),
)
_currentSession.value = discoveryDao.getSession(sessionId)
}
// endregion
// region Home preset restoration
private suspend fun restoreHomePreset() {
val preset = homePreset ?: return
shiftPreset(preset)
// Wait briefly for reconnection after restoring
waitForConnection()
}
// endregion
// region Lifecycle helpers
private fun cancelScanInternal() {
collectorRegistry.collector = null
dwellJob?.cancel()
dwellJob = null
scanScope?.cancel()
scanScope = null
}
// endregion
companion object {
private const val RECONNECT_TIMEOUT_MS = 60_000L
private const val TICK_INTERVAL_MS = 1_000L
private const val POSITION_DIVISOR = 1e7
private const val MIN_DEVICE_METRICS_PACKETS = 2
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.discovery
/**
* State machine for a discovery scan lifecycle.
*
* ```
* Idle → Shifting → [Reconnecting] → Dwell → Shifting (loop) → Analysis → Complete
* Any scanning → Restoring → Idle
* Reconnecting timeout → Paused
* ```
*/
sealed interface DiscoveryScanState {
/** No scan is active. */
data object Idle : DiscoveryScanState
/** Radio is switching to a new LoRa preset. */
data class Shifting(val presetName: String) : DiscoveryScanState
/** Waiting for the radio to reconnect after a preset change. */
data class Reconnecting(val presetName: String) : DiscoveryScanState
/** Listening on a preset and counting down the dwell timer. */
data class Dwell(val presetName: String, val remainingSeconds: Long, val totalSeconds: Long) : DiscoveryScanState
/** All presets scanned; aggregating results. */
data object Analysis : DiscoveryScanState
/** Scan finished and results are persisted. */
data object Complete : DiscoveryScanState
/** Scan paused due to an unrecoverable transient condition (e.g. reconnect timeout). */
data class Paused(val reason: String) : DiscoveryScanState
/** Restoring the home preset after scan stop or completion. */
data object Restoring : DiscoveryScanState
}

View File

@@ -0,0 +1,197 @@
/*
* 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.discovery
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.feature.discovery.ai.LoRaPresetReference
@Single
@Suppress("TooManyFunctions")
class DiscoverySummaryGenerator {
fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String {
if (presetResults.isEmpty()) return "No presets were scanned during this session."
val ranked =
presetResults.sortedWith(
compareByDescending<DiscoveryPresetResultEntity> { it.uniqueNodes }.thenBy { it.avgChannelUtilization },
)
val best = ranked.first()
val lines = buildList {
add(buildPresetComparisonLine(best, presetResults))
for (result in presetResults) {
if (result.id != best.id) {
add(buildAlternativeLine(result))
}
}
add(buildCongestionNote(presetResults))
add(buildTrafficMixNote(presetResults))
add(buildRecommendation(best, session))
}
return lines.filterNotNull().joinToString(" ")
}
fun generatePresetSummary(result: DiscoveryPresetResultEntity): String = buildString {
val info = LoRaPresetReference.getInfo(result.presetName)
append("${result.presetName}")
if (info != null) append(" (${info.dataRate}, ${info.linkBudget} link budget)")
append(": ${result.uniqueNodes} nodes")
append(" (${result.directNeighborCount} direct, ${result.meshNeighborCount} mesh)")
if (result.avgChannelUtilization > 0.0) {
append(", ${formatPercent(result.avgChannelUtilization)} channel utilization")
if (result.avgChannelUtilization > HIGH_CONGESTION_THRESHOLD) {
append(" (congested)")
}
}
if (result.messageCount > 0 || result.sensorPacketCount > 0) {
val dominant = if (result.messageCount >= result.sensorPacketCount) "chat" else "sensor"
append(", $dominant-dominated traffic")
}
append(".")
}
/** Build AI-style prompt for session-level analysis. Used by AI providers. */
fun buildSessionPrompt(session: DiscoverySessionEntity, presetResults: List<DiscoveryPresetResultEntity>): String =
buildString {
appendLine(
"Analyze this Meshtastic mesh radio discovery scan and recommend the best modem preset. " +
"Be concise (3-4 sentences).",
)
appendLine()
appendLine("Session: ${session.totalUniqueNodes} unique nodes, status: ${session.completionStatus}")
appendLine()
append(LoRaPresetReference.buildReferenceBlock(presetResults.map { it.presetName }))
appendLine("Channel util >25% indicates congestion; >50% causes significant packet loss.")
appendLine()
appendLine("Scan Results:")
for (result in presetResults) {
appendLine(formatPresetDataBlock(result))
}
appendLine()
append(
"Based on the scan data and preset reference, recommend which preset is best for this location. " +
"Consider node density, infrastructure count, channel utilization, airtime, and traffic mix. " +
"If congestion is high, recommend a faster preset.",
)
}
/** Build AI-style prompt for per-preset analysis. Used by AI providers. */
fun buildPresetPrompt(result: DiscoveryPresetResultEntity): String = buildString {
appendLine(
"Briefly summarize (1-2 sentences) the performance of the ${result.presetName} " +
"Meshtastic modem preset based on this scan data.",
)
appendLine()
val ref = LoRaPresetReference.formatReference(result.presetName)
if (ref != null) appendLine("Preset info: $ref")
appendLine("Channel util >25% indicates congestion; >50% causes significant packet loss.")
appendLine()
appendLine(formatPresetDataBlock(result))
appendLine()
append("Note if this preset is well-suited for the observed traffic pattern and node density.")
}
private fun formatPresetDataBlock(result: DiscoveryPresetResultEntity): String = buildString {
append(" ${result.presetName}: ")
append("Nodes: ${result.uniqueNodes} ")
append("(Direct: ${result.directNeighborCount}, Mesh: ${result.meshNeighborCount})")
append(", Messages: ${result.messageCount}, Sensor Packets: ${result.sensorPacketCount}")
if (result.avgChannelUtilization > 0.0) {
append(", Channel Util: ${formatPercent(result.avgChannelUtilization)}")
}
if (result.avgAirtimeRate > 0.0) {
append(", Airtime: ${formatPercent(result.avgAirtimeRate)}")
}
if (result.packetSuccessRate > 0.0) {
append(", Packet Success: ${formatPercent(result.packetSuccessRate * PERCENT_MULTIPLIER)}")
}
}
private fun buildPresetComparisonLine(
best: DiscoveryPresetResultEntity,
allResults: List<DiscoveryPresetResultEntity>,
): String {
val info = LoRaPresetReference.getInfo(best.presetName)
val rateStr = if (info != null) " (${info.dataRate})" else ""
if (allResults.size == 1) {
return "${best.presetName}$rateStr discovered ${best.uniqueNodes} node(s) " +
"with ${formatPercent(best.avgChannelUtilization)} channel utilization."
}
return "${best.presetName}$rateStr discovered the most nodes (${best.uniqueNodes}) " +
"with ${describeUtilization(best.avgChannelUtilization)} channel utilization " +
"(${formatPercent(best.avgChannelUtilization)})."
}
private fun buildAlternativeLine(result: DiscoveryPresetResultEntity): String {
val utilDesc = describeUtilization(result.avgChannelUtilization)
val utilPct = formatPercent(result.avgChannelUtilization)
return "${result.presetName} found ${result.uniqueNodes} node(s) " +
"with $utilDesc channel utilization ($utilPct)."
}
private fun buildCongestionNote(results: List<DiscoveryPresetResultEntity>): String? {
val congested = results.filter { it.avgChannelUtilization > HIGH_CONGESTION_THRESHOLD }
if (congested.isEmpty()) return null
return "High congestion detected on ${congested.joinToString { it.presetName }}; " +
"consider a faster preset to reduce airtime."
}
private fun buildTrafficMixNote(results: List<DiscoveryPresetResultEntity>): String? {
val chatDominant = results.filter { it.messageCount > it.sensorPacketCount }
val sensorDominant = results.filter { it.sensorPacketCount > it.messageCount }
val parts = buildList {
if (chatDominant.isNotEmpty()) {
add("chat-dominated on ${chatDominant.joinToString { it.presetName }}")
}
if (sensorDominant.isNotEmpty()) {
add("sensor-dominated on ${sensorDominant.joinToString { it.presetName }}")
}
}
if (parts.isEmpty()) return null
return "Traffic mix: ${parts.joinToString("; ")}."
}
private fun buildRecommendation(best: DiscoveryPresetResultEntity, session: DiscoverySessionEntity): String {
val status = if (session.completionStatus == "complete") "completed" else "partially completed"
return "Recommendation: Use ${best.presetName} for this location (scan $status)."
}
private fun describeUtilization(percent: Double): String = when {
percent < LOW_UTIL_THRESHOLD -> "low"
percent < MODERATE_UTIL_THRESHOLD -> "moderate"
percent < HIGH_UTIL_THRESHOLD -> "high"
else -> "very high"
}
private fun formatPercent(value: Double): String = "${NumberFormatter.format(value, 1)}%"
companion object {
private const val LOW_UTIL_THRESHOLD = 25.0
private const val MODERATE_UTIL_THRESHOLD = 50.0
private const val HIGH_UTIL_THRESHOLD = 75.0
private const val HIGH_CONGESTION_THRESHOLD = 25.0
private const val PERCENT_MULTIPLIER = 100.0
}
}

View File

@@ -0,0 +1,180 @@
/*
* 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.discovery
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.database.dao.DiscoveryDao
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.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.feature.discovery.export.DiscoveryExportData
import org.meshtastic.feature.discovery.export.DiscoveryExporter
import org.meshtastic.feature.discovery.export.ExportResult
@KoinViewModel
class DiscoverySummaryViewModel(
@InjectedParam private val sessionId: Long,
private val discoveryDao: DiscoveryDao,
private val summaryGenerator: DiscoverySummaryGenerator,
private val aiProvider: DiscoverySummaryAiProvider,
private val exporter: DiscoveryExporter,
) : ViewModel() {
val session: StateFlow<DiscoverySessionEntity?> =
discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null)
val presetResults: StateFlow<List<DiscoveryPresetResultEntity>> =
discoveryDao.getPresetResultsFlow(sessionId).stateInWhileSubscribed(initialValue = emptyList())
private val _nodesByPreset = MutableStateFlow<Map<Long, List<DiscoveredNodeEntity>>>(emptyMap())
val nodesByPreset: StateFlow<Map<Long, List<DiscoveredNodeEntity>>> = _nodesByPreset.asStateFlow()
private val _algorithmicSummary = MutableStateFlow<String?>(null)
val algorithmicSummary: StateFlow<String?> = _algorithmicSummary.asStateFlow()
private val _aiSummary = MutableStateFlow<String?>(null)
val aiSummary: StateFlow<String?> = _aiSummary.asStateFlow()
private val _presetAiSummaries = MutableStateFlow<Map<Long, String>>(emptyMap())
val presetAiSummaries: StateFlow<Map<Long, String>> = _presetAiSummaries.asStateFlow()
private val _isGeneratingAi = MutableStateFlow(false)
val isGeneratingAi: StateFlow<Boolean> = _isGeneratingAi.asStateFlow()
private val _exportResult = MutableStateFlow<ExportResult?>(null)
val exportResult: StateFlow<ExportResult?> = _exportResult.asStateFlow()
init {
loadNodes()
}
fun exportReport() {
safeLaunch(tag = "exportReport") {
val currentSession =
discoveryDao.getSession(sessionId)
?: run {
_exportResult.value = ExportResult.Error("Session not found")
return@safeLaunch
}
val results = discoveryDao.getPresetResults(sessionId)
val exportData =
DiscoveryExportData(
session = currentSession,
presetResults = results,
nodesByPreset = _nodesByPreset.value,
)
_exportResult.value = exporter.export(exportData)
}
}
fun clearExportResult() {
_exportResult.value = null
}
/** Re-run all AI analysis, clearing cached results first. */
fun rerunAnalysis() {
safeLaunch(tag = "rerunAnalysis") {
_isGeneratingAi.value = true
_aiSummary.value = null
_presetAiSummaries.value = emptyMap()
val currentSession = discoveryDao.getSession(sessionId) ?: return@safeLaunch
val results = discoveryDao.getPresetResults(sessionId)
// Clear persisted AI summaries
discoveryDao.updateSession(currentSession.copy(aiSummary = null))
for (result in results) {
discoveryDao.updatePresetResult(result.copy(aiSummary = null))
}
// Regenerate algorithmic
_algorithmicSummary.value = summaryGenerator.generateSessionSummary(currentSession, results)
// Regenerate AI
generateAiSummary(currentSession, results)
generatePresetAiSummaries(results)
_isGeneratingAi.value = false
}
}
private fun loadNodes() {
safeLaunch(tag = "loadNodes") {
val results = discoveryDao.getPresetResults(sessionId)
val nodesMap = mutableMapOf<Long, List<DiscoveredNodeEntity>>()
for (result in results) {
nodesMap[result.id] = discoveryDao.getDiscoveredNodes(result.id)
}
_nodesByPreset.value = nodesMap
// Load cached per-preset AI summaries
val cachedPresetSummaries =
results.filter { !it.aiSummary.isNullOrBlank() }.associate { it.id to it.aiSummary!! }
_presetAiSummaries.value = cachedPresetSummaries
val session = discoveryDao.getSession(sessionId)
if (session != null) {
_algorithmicSummary.value = summaryGenerator.generateSessionSummary(session, results)
// Use cached AI summary if available, otherwise generate
if (!session.aiSummary.isNullOrBlank()) {
_aiSummary.value = session.aiSummary
} else {
generateAiSummary(session, results)
}
// Generate per-preset summaries for any without cached results
val uncached = results.filter { it.aiSummary.isNullOrBlank() && it.uniqueNodes > 0 }
if (uncached.isNotEmpty()) {
generatePresetAiSummaries(uncached)
}
}
}
}
private fun generateAiSummary(session: DiscoverySessionEntity, results: List<DiscoveryPresetResultEntity>) {
if (!aiProvider.isAvailable) return
safeLaunch(tag = "aiSummary") {
val summary = aiProvider.generateSessionSummary(session, results)
if (summary != null) {
_aiSummary.value = summary
discoveryDao.updateSession(session.copy(aiSummary = summary))
}
}
}
private fun generatePresetAiSummaries(results: List<DiscoveryPresetResultEntity>) {
if (!aiProvider.isAvailable) return
safeLaunch(tag = "presetAiSummaries") {
for (result in results) {
val summary = aiProvider.generatePresetSummary(result)
if (summary != null) {
_presetAiSummaries.value = _presetAiSummaries.value + (result.id to summary)
discoveryDao.updatePresetResult(result.copy(aiSummary = summary))
}
}
}
}
}

View File

@@ -0,0 +1,99 @@
/*
* 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.discovery
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.database.dao.DiscoveryDao
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.repository.RadioConfigRepository
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
@Suppress("MagicNumber")
@KoinViewModel
class DiscoveryViewModel(
private val scanEngine: DiscoveryScanEngine,
private val serviceRepository: ServiceRepository,
radioConfigRepository: RadioConfigRepository,
discoveryDao: DiscoveryDao,
) : ViewModel() {
val scanState: StateFlow<DiscoveryScanState> = scanEngine.scanState
val currentSession: StateFlow<DiscoverySessionEntity?> = scanEngine.currentSession
val connectionState: StateFlow<ConnectionState> = serviceRepository.connectionState
val homePreset: StateFlow<ChannelOption> =
radioConfigRepository.localConfigFlow
.map { localConfig ->
val presetEnum = localConfig.lora?.modem_preset
ChannelOption.entries.firstOrNull { it.modemPreset == presetEnum } ?: ChannelOption.DEFAULT
}
.stateInWhileSubscribed(initialValue = ChannelOption.DEFAULT)
private val _selectedPresets = MutableStateFlow<Set<ChannelOption>>(emptySet())
val selectedPresets: StateFlow<Set<ChannelOption>> = _selectedPresets.asStateFlow()
private val _dwellDurationMinutes = MutableStateFlow(DEFAULT_DWELL_MINUTES)
val dwellDurationMinutes: StateFlow<Int> = _dwellDurationMinutes.asStateFlow()
val isConnected: StateFlow<Boolean> =
serviceRepository.connectionState
.map { it is ConnectionState.Connected }
.stateInWhileSubscribed(initialValue = false)
val sessions: StateFlow<List<DiscoverySessionEntity>> =
discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList())
fun togglePreset(preset: ChannelOption) {
_selectedPresets.update { current -> if (preset in current) current - preset else current + preset }
}
fun setDwellDuration(minutes: Int) {
_dwellDurationMinutes.value = minutes
}
fun startScan() {
safeLaunch(tag = "startScan") {
scanEngine.startScan(
presets = selectedPresets.value.toList(),
dwellDurationSeconds = dwellDurationMinutes.value.toLong() * SECONDS_PER_MINUTE,
)
}
}
fun stopScan() {
safeLaunch(tag = "stopScan") { scanEngine.stopScan() }
}
fun reset() {
scanEngine.reset()
}
companion object {
private const val DEFAULT_DWELL_MINUTES = 15
private const val SECONDS_PER_MINUTE = 60L
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.discovery.ai
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
/**
* Abstraction for generating natural-language summaries of discovery scan results.
*
* Platform implementations may use on-device AI (e.g. Gemini Nano on Android) or fall back to the algorithmic
* [org.meshtastic.feature.discovery.DiscoverySummaryGenerator].
*/
interface DiscoverySummaryAiProvider {
/** Whether this provider is ready to generate AI summaries. */
val isAvailable: Boolean
/** Generate a session-level summary across all preset results. Returns `null` on failure. */
suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String?
/** Generate a per-preset summary. Returns `null` on failure. */
suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String?
}

View File

@@ -0,0 +1,130 @@
/*
* 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.discovery.ai
/**
* LoRa modem preset reference data for enriching AI prompts and algorithmic summaries. Data sourced from Meshtastic
* radio-settings documentation.
*/
internal object LoRaPresetReference {
data class PresetInfo(
val bandwidth: String,
val spreadingFactor: String,
val dataRate: String,
val linkBudget: String,
val description: String,
)
private val presets =
mapOf(
"Long Fast" to
PresetInfo(
"250kHz",
"SF11",
"1.07kbps",
"153dB",
"Default. Good range but high airtime per packet; causes congestion in networks >60 nodes.",
),
"Long Moderate" to
PresetInfo(
"125kHz",
"SF11",
"0.34kbps",
"155.5dB",
"Maximum range but extremely slow; only suitable for very sparse, long-range deployments.",
),
"Long Slow" to
PresetInfo(
"125kHz",
"SF12",
"0.18kbps",
"158dB",
"Extreme range, extremely slow; only for point-to-point long-range links.",
),
"Long Turbo" to
PresetInfo(
"500kHz",
"SF9",
"7.03kbps",
"148dB",
"Fast long-range. ~7x LongFast speed, reduced range. Good balance for moderate networks.",
),
"Medium Slow" to
PresetInfo(
"250kHz",
"SF10",
"1.95kbps",
"150.5dB",
"~2x LongFast speed. Bay Area mesh (150+ nodes) thrives on this preset.",
),
"Medium Fast" to
PresetInfo(
"250kHz",
"SF9",
"3.52kbps",
"148dB",
"~3.5x LongFast speed. Excellent balance for dense urban/suburban networks.",
),
"Short Slow" to
PresetInfo(
"250kHz",
"SF8",
"6.25kbps",
"145.5dB",
"~6x LongFast speed. Good for dense networks with adequate node spacing.",
),
"Short Fast" to
PresetInfo(
"250kHz",
"SF7",
"10.94kbps",
"143dB",
"~10x LongFast speed. Wellington NZ mesh (150+ nodes) switched here with excellent results.",
),
"Short Turbo" to
PresetInfo(
"500kHz",
"SF7",
"21.88kbps",
"140dB",
"Maximum speed, minimum range. Only for very dense, close-proximity deployments.",
),
)
/** Get reference data for a preset, matching by substring (e.g. "Long Fast" matches "Long Fast"). */
fun getInfo(presetName: String): PresetInfo? =
presets.entries.firstOrNull { presetName.contains(it.key, ignoreCase = true) }?.value
/** Format a one-line reference string for a preset. */
fun formatReference(presetName: String): String? {
val info = getInfo(presetName) ?: return null
return "$presetName: ${info.bandwidth} BW, ${info.spreadingFactor}, " +
"${info.dataRate}, ${info.linkBudget} link budget. ${info.description}"
}
/** Build a multi-line reference block for all scanned presets. */
fun buildReferenceBlock(presetNames: List<String>): String = buildString {
appendLine("LoRa Preset Reference:")
for (name in presetNames) {
val ref = formatReference(name)
if (ref != null) {
appendLine(" $ref")
}
}
}
}

View File

@@ -0,0 +1,24 @@
/*
* 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.discovery.di
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
@Module
@ComponentScan("org.meshtastic.feature.discovery")
class FeatureDiscoveryModule

View File

@@ -0,0 +1,37 @@
/*
* 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.discovery.export
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
data class DiscoveryExportData(
val session: DiscoverySessionEntity,
val presetResults: List<DiscoveryPresetResultEntity>,
val nodesByPreset: Map<Long, List<DiscoveredNodeEntity>>,
)
interface DiscoveryExporter {
suspend fun export(data: DiscoveryExportData): ExportResult
}
sealed interface ExportResult {
data class Success(val content: ByteArray, val mimeType: String, val fileName: String) : ExportResult
data class Error(val message: String) : ExportResult
}

View File

@@ -0,0 +1,73 @@
/*
* 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.discovery.export
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.feature.discovery.ui.formatDuration
internal object DiscoveryReportFormatter {
fun formatSessionDate(session: DiscoverySessionEntity): String = DateFormatter.formatDateTime(session.timestamp)
fun formatSessionOverviewLines(session: DiscoverySessionEntity): List<Pair<String, String>> = listOf(
"Date" to formatSessionDate(session),
"Total unique nodes" to session.totalUniqueNodes.toString(),
"Total dwell time" to formatDuration(session.totalDwellSeconds),
"Status" to session.completionStatus.replaceFirstChar { it.uppercase() },
"Channel utilization" to "${NumberFormatter.format(session.avgChannelUtilization, 1)}%",
"Total messages" to session.totalMessages.toString(),
"Total sensor packets" to session.totalSensorPackets.toString(),
)
fun formatPresetLines(result: DiscoveryPresetResultEntity): List<Pair<String, String>> = buildList {
add("Unique nodes" to result.uniqueNodes.toString())
add("Direct neighbors" to result.directNeighborCount.toString())
add("Mesh neighbors" to result.meshNeighborCount.toString())
add("Dwell time" to formatDuration(result.dwellDurationSeconds))
add("Channel utilization" to "${NumberFormatter.format(result.avgChannelUtilization, 1)}%")
add("Airtime rate" to "${NumberFormatter.format(result.avgAirtimeRate, 1)}%")
add("Packet success" to "${NumberFormatter.format(result.packetSuccessRate, 1)}%")
add("Messages" to result.messageCount.toString())
add("Packets TX" to result.numPacketsTx.toString())
add("Packets RX" to result.numPacketsRx.toString())
val aiText = result.aiSummary
if (!aiText.isNullOrBlank()) {
add("Analysis" to aiText)
}
}
fun formatNodeLine(node: DiscoveredNodeEntity): String = buildString {
append(node.longName ?: node.shortName ?: "!${node.nodeNum.toString(radix = 16)}")
append(" | ${node.neighborType}")
append(" | SNR: ${NumberFormatter.format(node.snr, 1)}")
append(" | RSSI: ${node.rssi}")
val distance = node.distanceFromUser
if (distance != null) {
append(" | ${NumberFormatter.format(distance, 0)}m")
}
}
fun generateFileName(session: DiscoverySessionEntity, extension: String): String {
val dateStr =
DateFormatter.formatDateTime(session.timestamp).replace(" ", "_").replace("/", "-").replace(":", "-")
return "meshtastic_discovery_$dateStr.$extension"
}
}

View File

@@ -0,0 +1,84 @@
/*
* 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.discovery.navigation
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.dropUnlessResumed
import androidx.navigation3.runtime.EntryProviderScope
import androidx.navigation3.runtime.NavBackStack
import androidx.navigation3.runtime.NavKey
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.core.navigation.DiscoveryRoute
import org.meshtastic.feature.discovery.DiscoveryHistoryDetailViewModel
import org.meshtastic.feature.discovery.DiscoveryHistoryViewModel
import org.meshtastic.feature.discovery.DiscoveryMapViewModel
import org.meshtastic.feature.discovery.DiscoverySummaryViewModel
import org.meshtastic.feature.discovery.DiscoveryViewModel
import org.meshtastic.feature.discovery.ui.DiscoveryHistoryDetailScreen
import org.meshtastic.feature.discovery.ui.DiscoveryHistoryScreen
import org.meshtastic.feature.discovery.ui.DiscoveryMapScreen
import org.meshtastic.feature.discovery.ui.DiscoveryScanScreen
import org.meshtastic.feature.discovery.ui.DiscoverySummaryScreen
/** Registers the discovery feature screen entries into the Navigation 3 entry provider. */
fun EntryProviderScope<NavKey>.discoveryGraph(backStack: NavBackStack<NavKey>) {
entry<DiscoveryRoute.DiscoveryGraph> { DiscoveryScanScreenEntry(backStack) }
entry<DiscoveryRoute.DiscoveryScan> { DiscoveryScanScreenEntry(backStack) }
entry<DiscoveryRoute.DiscoverySummary> { route ->
val viewModel = koinViewModel<DiscoverySummaryViewModel> { parametersOf(route.sessionId) }
DiscoverySummaryScreen(
viewModel = viewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToMap = { sessionId -> backStack.add(DiscoveryRoute.DiscoveryMap(sessionId)) },
)
}
entry<DiscoveryRoute.DiscoveryMap> { route ->
val viewModel = koinViewModel<DiscoveryMapViewModel> { parametersOf(route.sessionId) }
DiscoveryMapScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() })
}
entry<DiscoveryRoute.DiscoveryHistory> {
val viewModel = koinViewModel<DiscoveryHistoryViewModel>()
val navigateToDetail: (Long) -> Unit = { sessionId ->
backStack.add(DiscoveryRoute.DiscoveryHistoryDetail(sessionId))
}
DiscoveryHistoryScreen(
viewModel = viewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToDetail = navigateToDetail,
)
}
entry<DiscoveryRoute.DiscoveryHistoryDetail> { route ->
val viewModel = koinViewModel<DiscoveryHistoryDetailViewModel> { parametersOf(route.sessionId) }
DiscoveryHistoryDetailScreen(
viewModel = viewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToMap = { sessionId -> backStack.add(DiscoveryRoute.DiscoveryMap(sessionId)) },
)
}
}
@Composable
private fun DiscoveryScanScreenEntry(backStack: NavBackStack<NavKey>) {
val viewModel = koinViewModel<DiscoveryViewModel>()
DiscoveryScanScreen(
viewModel = viewModel,
onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() },
onNavigateToSummary = { sessionId -> backStack.add(DiscoveryRoute.DiscoverySummary(sessionId)) },
onNavigateToHistory = dropUnlessResumed { backStack.add(DiscoveryRoute.DiscoveryHistory) },
)
}

View File

@@ -0,0 +1,141 @@
/*
* 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.discovery.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.feature.discovery.DiscoveryHistoryDetailViewModel
import org.meshtastic.feature.discovery.ui.component.PresetResultCard
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoveryHistoryDetailScreen(
viewModel: DiscoveryHistoryDetailViewModel,
onNavigateUp: () -> Unit,
onNavigateToMap: (Long) -> Unit,
) {
val session by viewModel.session.collectAsStateWithLifecycle()
val presetResults by viewModel.presetResults.collectAsStateWithLifecycle()
val nodesByPreset by viewModel.nodesByPreset.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Session Detail") },
navigationIcon = {
IconButton(onClick = onNavigateUp) { Icon(MeshtasticIcons.ArrowBack, contentDescription = "Back") }
},
actions = {
val s = session
val hasAnyMappableNodes =
nodesByPreset.values.flatten().any {
it.latitude != null && it.longitude != null && it.latitude != 0.0
}
if (s != null && (s.userLatitude != 0.0 || hasAnyMappableNodes)) {
IconButton(onClick = { onNavigateToMap(s.id) }) {
Icon(MeshtasticIcons.Map, contentDescription = "View map")
}
}
},
)
},
) { padding ->
Column(
modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
session?.let { s -> SessionMetadataCard(s) }
if (presetResults.isNotEmpty()) {
Text(text = "Preset Results", style = MaterialTheme.typography.titleMedium)
presetResults.forEach { result ->
PresetResultCard(result = result, nodes = nodesByPreset[result.id].orEmpty())
}
}
}
}
}
@Composable
private fun SessionMetadataCard(session: DiscoverySessionEntity) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = formatTimestamp(session.timestamp), style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(8.dp))
MetadataRow("Status", session.completionStatus.replaceFirstChar { it.uppercase() })
MetadataRow("Presets scanned", session.presetsScanned)
MetadataRow("Home preset", session.homePreset)
MetadataRow("Unique nodes", session.totalUniqueNodes.toString())
MetadataRow("Total messages", session.totalMessages.toString())
MetadataRow("Total dwell time", formatDuration(session.totalDwellSeconds))
session.aiSummary?.let { summary ->
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Text(
text = summary,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun MetadataRow(label: String, value: String) {
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(140.dp),
)
Text(text = value, style = MaterialTheme.typography.bodyMedium)
}
}

View File

@@ -0,0 +1,204 @@
/*
* 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.discovery.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.CheckCircle
import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.History
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.feature.discovery.DiscoveryHistoryViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoveryHistoryScreen(
viewModel: DiscoveryHistoryViewModel,
onNavigateUp: () -> Unit,
onNavigateToDetail: (sessionId: Long) -> Unit,
) {
val sessions by viewModel.sessions.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Discovery History") },
navigationIcon = {
IconButton(onClick = onNavigateUp) { Icon(MeshtasticIcons.ArrowBack, contentDescription = "Back") }
},
)
},
) { padding ->
if (sessions.isEmpty()) {
EmptyHistoryState(modifier = Modifier.padding(padding).fillMaxSize())
} else {
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
) {
items(sessions, key = { it.id }) { session ->
SessionListItem(
session = session,
onClick = { onNavigateToDetail(session.id) },
onDelete = { viewModel.deleteSession(session.id) },
)
}
}
}
}
}
@Composable
private fun EmptyHistoryState(modifier: Modifier = Modifier) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = MeshtasticIcons.History,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Text(
text = "No discovery sessions yet",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun SessionListItem(session: DiscoverySessionEntity, onClick: () -> Unit, onDelete: () -> Unit) {
var showDeleteDialog by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)) {
Row(modifier = Modifier.padding(16.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
CompletionStatusIcon(session.completionStatus)
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = formatTimestamp(session.timestamp), style = MaterialTheme.typography.titleSmall)
Spacer(Modifier.height(4.dp))
Text(
text = session.presetsScanned,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(2.dp))
Text(
text = "${session.totalUniqueNodes} unique nodes",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = { showDeleteDialog = true }) {
Icon(
imageVector = MeshtasticIcons.Delete,
contentDescription = "Delete session",
tint = MaterialTheme.colorScheme.error,
)
}
}
}
if (showDeleteDialog) {
DeleteConfirmationDialog(
onConfirm = {
onDelete()
showDeleteDialog = false
},
onDismiss = { showDeleteDialog = false },
)
}
}
@Composable
private fun CompletionStatusIcon(status: String) {
if (status == "complete") {
Icon(
imageVector = MeshtasticIcons.CheckCircle,
contentDescription = "Complete",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
} else {
Icon(
imageVector = MeshtasticIcons.Warning,
contentDescription = "Incomplete",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(24.dp),
)
}
}
@Composable
private fun DeleteConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Session") },
text = { Text("Are you sure you want to delete this discovery session? This action cannot be undone.") },
confirmButton = { TextButton(onClick = onConfirm) { Text("Delete", color = MaterialTheme.colorScheme.error) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@Suppress("MagicNumber")
internal fun formatTimestamp(epochMillis: Long): String {
val instant = epochMillis.toInstant()
val local = instant.toLocalDateTime(TimeZone.currentSystemDefault())
return "${local.year}-${local.monthNumber.toString().padStart(2, '0')}-" +
"${local.dayOfMonth.toString().padStart(2, '0')} " +
"${local.hour.toString().padStart(2, '0')}:${local.minute.toString().padStart(2, '0')}"
}

View File

@@ -0,0 +1,100 @@
/*
* 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.discovery.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.util.DiscoveryMapNode
import org.meshtastic.core.ui.util.DiscoveryNeighborType
import org.meshtastic.core.ui.util.LocalDiscoveryMapProvider
import org.meshtastic.feature.discovery.DiscoveryMapViewModel
/**
* Full-screen map showing all discovered nodes from a scan session. Delegates to the flavor-specific map implementation
* via [LocalDiscoveryMapProvider].
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoveryMapScreen(viewModel: DiscoveryMapViewModel, onNavigateUp: () -> Unit) {
val session by viewModel.session.collectAsStateWithLifecycle()
val allNodes by viewModel.allNodes.collectAsStateWithLifecycle()
val discoveryMap = LocalDiscoveryMapProvider.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("Discovery Map") },
navigationIcon = {
IconButton(onClick = onNavigateUp) { Icon(MeshtasticIcons.ArrowBack, contentDescription = "Back") }
},
)
},
) { padding ->
val currentSession = session
if (currentSession == null) {
Box(modifier = Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
val mapNodes =
allNodes.mapNotNull { entity ->
val lat = entity.latitude ?: return@mapNotNull null
val lon = entity.longitude ?: return@mapNotNull null
if (lat == 0.0 && lon == 0.0) return@mapNotNull null
DiscoveryMapNode(
latitude = lat,
longitude = lon,
shortName = entity.shortName,
longName = entity.longName,
neighborType =
if (entity.neighborType == "direct") {
DiscoveryNeighborType.DIRECT
} else {
DiscoveryNeighborType.MESH
},
snr = entity.snr,
rssi = entity.rssi,
messageCount = entity.messageCount,
sensorPacketCount = entity.sensorPacketCount,
)
}
discoveryMap(
currentSession.userLatitude,
currentSession.userLongitude,
mapNodes,
Modifier.fillMaxSize().padding(padding),
)
}
}

View File

@@ -0,0 +1,342 @@
/*
* 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/>.
*/
@file:Suppress("TooManyFunctions", "MagicNumber")
package org.meshtastic.feature.discovery.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.ui.component.SwitchPreference
import org.meshtastic.core.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.Close
import org.meshtastic.core.ui.icon.History
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PlayArrow
import org.meshtastic.core.ui.icon.Warning
import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.feature.discovery.DiscoveryScanState
import org.meshtastic.feature.discovery.DiscoveryViewModel
import org.meshtastic.feature.discovery.ui.component.DwellProgressIndicator
import org.meshtastic.feature.discovery.ui.component.PresetPickerCard
private val CONTENT_PADDING = 16.dp
private val SECTION_SPACING = 16.dp
private val DWELL_OPTIONS = listOf(1, 5, 15, 30, 45, 60, 90, 120, 180)
/** Main scan screen for the Local Mesh Discovery feature. */
@Suppress("LongMethod")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoveryScanScreen(
viewModel: DiscoveryViewModel,
onNavigateUp: () -> Unit,
onNavigateToSummary: (sessionId: Long) -> Unit,
onNavigateToHistory: () -> Unit,
) {
val scanState by viewModel.scanState.collectAsStateWithLifecycle()
val selectedPresets by viewModel.selectedPresets.collectAsStateWithLifecycle()
val dwellMinutes by viewModel.dwellDurationMinutes.collectAsStateWithLifecycle()
val isConnected by viewModel.isConnected.collectAsStateWithLifecycle()
val currentSession by viewModel.currentSession.collectAsStateWithLifecycle()
val homePreset by viewModel.homePreset.collectAsStateWithLifecycle()
var keepScreenAwake by rememberSaveable { mutableStateOf(true) }
val isScanning = scanState !is DiscoveryScanState.Idle
// Keep screen awake while a scan is in progress
KeepScreenOn(isScanning && keepScreenAwake)
// Navigate to summary when scan completes
LaunchedEffect(scanState) {
if (scanState is DiscoveryScanState.Complete) {
currentSession?.id?.let { sessionId ->
viewModel.reset()
onNavigateToSummary(sessionId)
}
}
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("Local Mesh Discovery") },
navigationIcon = {
IconButton(onClick = onNavigateUp) {
Icon(imageVector = MeshtasticIcons.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = onNavigateToHistory) {
Icon(imageVector = MeshtasticIcons.History, contentDescription = "Scan History")
}
},
)
},
bottomBar = {
androidx.compose.material3.Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shadowElevation = 8.dp,
) {
androidx.compose.foundation.layout.Box(
modifier = Modifier.padding(horizontal = CONTENT_PADDING, vertical = 16.dp),
) {
ScanButton(
scanState = scanState,
isConnected = isConnected,
hasPresetsSelected = selectedPresets.isNotEmpty(),
onStart = viewModel::startScan,
onStop = viewModel::stopScan,
)
}
}
},
) { padding ->
LazyColumn(
contentPadding = padding,
verticalArrangement = Arrangement.spacedBy(SECTION_SPACING),
modifier = Modifier.fillMaxSize().padding(horizontal = CONTENT_PADDING).padding(top = SECTION_SPACING),
) {
// Connection warning
if (!isConnected) {
item(key = "connection_warning") { ConnectionWarningCard() }
}
if (!isScanning) {
// Preset picker
item(key = "preset_picker") {
PresetPickerCard(
selectedPresets = selectedPresets,
homePreset = homePreset,
onTogglePreset = viewModel::togglePreset,
enabled = true,
)
}
// Dwell time picker
item(key = "dwell_picker") {
DwellTimePicker(
selectedMinutes = dwellMinutes,
onMinutesSelected = viewModel::setDwellDuration,
enabled = true,
)
}
// Keep awake toggle
item(key = "keep_awake_toggle") {
KeepAwakeToggleCard(keepAwake = keepScreenAwake, onToggle = { keepScreenAwake = it })
}
}
// Scan progress section
if (isScanning) {
item(key = "scan_progress") { ScanProgressSection(scanState = scanState) }
}
// Bottom spacer
item { Spacer(modifier = Modifier.height(SECTION_SPACING)) }
}
}
}
@Composable
private fun KeepAwakeToggleCard(keepAwake: Boolean, onToggle: (Boolean) -> Unit, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
SwitchPreference(
title = "Keep screen awake",
summary = "Prevents Android Doze mode from dropping radio packets during long scans. Recommended.",
checked = keepAwake,
enabled = true,
onCheckedChange = onToggle,
)
}
}
@Composable
private fun ConnectionWarningCard(modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(CONTENT_PADDING),
) {
Icon(
imageVector = MeshtasticIcons.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Column {
Text(
text = "Not Connected",
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.error,
)
Text(
text = "Connect to a Meshtastic device to start scanning.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun DwellTimePicker(
selectedMinutes: Int,
onMinutesSelected: (Int) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(false) }
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(CONTENT_PADDING)) {
Text(text = "Dwell Time", style = MaterialTheme.typography.titleMedium)
Text(
text = "Time to listen on each preset",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp),
)
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { if (enabled) expanded = it }) {
OutlinedTextField(
value = "$selectedMinutes min",
onValueChange = {},
readOnly = true,
enabled = enabled,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable),
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
DWELL_OPTIONS.forEach { minutes ->
DropdownMenuItem(
text = { Text("$minutes min") },
onClick = {
onMinutesSelected(minutes)
expanded = false
},
)
}
}
}
}
}
}
@Composable
private fun ScanButton(
scanState: DiscoveryScanState,
isConnected: Boolean,
hasPresetsSelected: Boolean,
onStart: () -> Unit,
onStop: () -> Unit,
modifier: Modifier = Modifier,
) {
val isScanning = scanState !is DiscoveryScanState.Idle
if (isScanning) {
OutlinedButton(
onClick = onStop,
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
modifier = modifier.fillMaxWidth(),
) {
Icon(imageVector = MeshtasticIcons.Close, contentDescription = null)
Text("Stop Scan", modifier = Modifier.padding(start = 8.dp))
}
} else {
Button(onClick = onStart, enabled = isConnected && hasPresetsSelected, modifier = modifier.fillMaxWidth()) {
Icon(imageVector = MeshtasticIcons.PlayArrow, contentDescription = null)
Text("Start Scan", modifier = Modifier.padding(start = 8.dp))
}
}
}
@Composable
private fun ScanProgressSection(scanState: DiscoveryScanState, modifier: Modifier = Modifier) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(CONTENT_PADDING)) {
Text(text = "Scan Progress", style = MaterialTheme.typography.titleMedium)
when (scanState) {
is DiscoveryScanState.Shifting -> {
Text(text = "Shifting to ${scanState.presetName}", style = MaterialTheme.typography.bodyMedium)
}
is DiscoveryScanState.Reconnecting -> {
Text(text = "Reconnecting on ${scanState.presetName}", style = MaterialTheme.typography.bodyMedium)
}
is DiscoveryScanState.Dwell -> {
DwellProgressIndicator(
presetName = scanState.presetName,
remainingSeconds = scanState.remainingSeconds,
totalSeconds = scanState.totalSeconds,
)
}
is DiscoveryScanState.Analysis -> {
Text(text = "Analyzing results…", style = MaterialTheme.typography.bodyMedium)
}
is DiscoveryScanState.Restoring -> {
Text(text = "Restoring home preset…", style = MaterialTheme.typography.bodyMedium)
}
is DiscoveryScanState.Paused -> {
Text(
text = "Paused: ${scanState.reason}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
}
is DiscoveryScanState.Complete,
is DiscoveryScanState.Idle,
-> Unit
}
}
}
}

View File

@@ -0,0 +1,264 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.meshtastic.core.common.util.DateFormatter
import org.meshtastic.core.common.util.NumberFormatter
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.ui.icon.ArrowBack
import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.icon.Share
import org.meshtastic.feature.discovery.DiscoverySummaryViewModel
import org.meshtastic.feature.discovery.export.ExportResult
import org.meshtastic.feature.discovery.ui.component.PresetResultCard
@Composable
fun DiscoverySummaryScreen(
viewModel: DiscoverySummaryViewModel,
onNavigateUp: () -> Unit,
onNavigateToMap: (Long) -> Unit,
) {
val session by viewModel.session.collectAsStateWithLifecycle()
val presetResults by viewModel.presetResults.collectAsStateWithLifecycle()
val nodesByPreset by viewModel.nodesByPreset.collectAsStateWithLifecycle()
val algorithmicSummary by viewModel.algorithmicSummary.collectAsStateWithLifecycle()
val aiSummary by viewModel.aiSummary.collectAsStateWithLifecycle()
val presetAiSummaries by viewModel.presetAiSummaries.collectAsStateWithLifecycle()
val isGeneratingAi by viewModel.isGeneratingAi.collectAsStateWithLifecycle()
val exportResult by viewModel.exportResult.collectAsStateWithLifecycle()
LaunchedEffect(exportResult) {
when (exportResult) {
is ExportResult.Success -> {
// TODO: Wire platform share intent (Android) / file-save dialog (Desktop)
viewModel.clearExportResult()
}
is ExportResult.Error -> {
// TODO: Show snackbar with error message
viewModel.clearExportResult()
}
null -> {
/* no-op */
}
}
}
DiscoverySummaryContent(
session = session,
presetResults = presetResults,
nodesByPreset = nodesByPreset,
algorithmicSummary = algorithmicSummary,
aiSummary = aiSummary,
presetAiSummaries = presetAiSummaries,
isGeneratingAi = isGeneratingAi,
onNavigateUp = onNavigateUp,
onNavigateToMap = onNavigateToMap,
onExport = viewModel::exportReport,
onRerunAnalysis = viewModel::rerunAnalysis,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Suppress("LongParameterList")
private fun DiscoverySummaryContent(
session: DiscoverySessionEntity?,
presetResults: List<DiscoveryPresetResultEntity>,
nodesByPreset: Map<Long, List<DiscoveredNodeEntity>>,
algorithmicSummary: String?,
aiSummary: String?,
presetAiSummaries: Map<Long, String>,
isGeneratingAi: Boolean,
onNavigateUp: () -> Unit,
onNavigateToMap: (Long) -> Unit,
onExport: () -> Unit,
onRerunAnalysis: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Scan Summary") },
navigationIcon = {
IconButton(onClick = onNavigateUp) { Icon(MeshtasticIcons.ArrowBack, contentDescription = "Back") }
},
actions = {
if (session != null) {
IconButton(onClick = { onNavigateToMap(session.id) }) {
Icon(MeshtasticIcons.Map, contentDescription = "View map")
}
}
IconButton(onClick = onExport) { Icon(MeshtasticIcons.Share, contentDescription = "Export report") }
},
)
},
) { padding ->
if (session == null) {
CircularProgressIndicator(modifier = Modifier.fillMaxSize().padding(padding))
return@Scaffold
}
LazyColumn(
modifier = Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item { Spacer(modifier = Modifier.height(4.dp)) }
item { SessionOverviewCard(session = session) }
items(presetResults, key = { it.id }) { result ->
PresetResultCard(
result = result,
nodes = nodesByPreset[result.id].orEmpty(),
aiSummary = presetAiSummaries[result.id],
)
}
item {
AiSummaryCard(
aiSummary = aiSummary ?: session.aiSummary,
algorithmicSummary = algorithmicSummary,
isGenerating = isGeneratingAi,
onRerunAnalysis = onRerunAnalysis,
)
}
item { Spacer(modifier = Modifier.height(16.dp)) }
}
}
}
@Composable
private fun SessionOverviewCard(session: DiscoverySessionEntity) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "Session Overview", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
StatRow(label = "Date", value = DateFormatter.formatDateTime(session.timestamp))
StatRow(label = "Total unique nodes", value = session.totalUniqueNodes.toString())
StatRow(label = "Total dwell time", value = formatDuration(session.totalDwellSeconds))
StatRow(label = "Status", value = session.completionStatus.replaceFirstChar { it.uppercase() })
StatRow(
label = "Channel utilization",
value = "${NumberFormatter.format(session.avgChannelUtilization, 1)}%",
)
}
}
}
@Composable
private fun AiSummaryCard(
aiSummary: String?,
algorithmicSummary: String?,
isGenerating: Boolean,
onRerunAnalysis: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "Analysis", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
if (isGenerating) {
CircularProgressIndicator(modifier = Modifier.padding(4.dp), strokeWidth = 2.dp)
} else {
IconButton(onClick = onRerunAnalysis) {
Icon(
MeshtasticIcons.Refresh,
contentDescription = "Re-run analysis",
tint = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
val summaryText = aiSummary ?: algorithmicSummary ?: "AI analysis not available"
Text(
text = summaryText,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer,
)
}
}
}
@Composable
internal fun StatRow(label: String, value: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier.fillMaxWidth().padding(vertical = 2.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
}
}
internal fun formatDuration(totalSeconds: Long): String {
val minutes = totalSeconds / 60
val hours = minutes / 60
val remainingMinutes = minutes % 60
return if (hours > 0) "${hours}h ${remainingMinutes}m" else "${minutes}m"
}

View File

@@ -0,0 +1,62 @@
/*
* 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.discovery.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Suppress("MagicNumber")
private val CONTENT_PADDING = 8.dp
private const val SECONDS_PER_MINUTE = 60L
/** Displays dwell progress for a single preset with a countdown timer and linear progress bar. */
@Composable
fun DwellProgressIndicator(
presetName: String,
remainingSeconds: Long,
totalSeconds: Long,
modifier: Modifier = Modifier,
) {
val progress =
if (totalSeconds > 0) {
1f - (remainingSeconds.toFloat() / totalSeconds.toFloat())
} else {
0f
}
val minutes = remainingSeconds / SECONDS_PER_MINUTE
val seconds = remainingSeconds % SECONDS_PER_MINUTE
val timeText = "%02d:%02d".format(minutes, seconds)
Column(verticalArrangement = Arrangement.spacedBy(CONTENT_PADDING), modifier = modifier.fillMaxWidth()) {
Text(text = "Dwelling on $presetName", style = MaterialTheme.typography.titleSmall)
LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
Text(
text = "$timeText remaining",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = CONTENT_PADDING / 2),
)
}
}

View File

@@ -0,0 +1,96 @@
/*
* 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.discovery.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.meshtastic.core.model.ChannelOption
import org.meshtastic.core.ui.icon.Check
import org.meshtastic.core.ui.icon.MeshtasticIcons
@Suppress("MagicNumber")
private val CHIP_SPACING = 8.dp
private val CARD_PADDING = 16.dp
/** Formats a [ChannelOption] enum name (e.g. "LONG_FAST") into a human-readable label (e.g. "Long Fast"). */
internal fun ChannelOption.displayName(): String =
name.split("_").joinToString(" ") { word -> word.lowercase().replaceFirstChar { it.uppercase() } }
/** A card containing a [FlowRow] of [FilterChip] items for preset selection. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun PresetPickerCard(
selectedPresets: Set<ChannelOption>,
homePreset: ChannelOption,
onTogglePreset: (ChannelOption) -> Unit,
enabled: Boolean,
modifier: Modifier = Modifier,
) {
ElevatedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(CARD_PADDING)) {
Text(text = "LoRa Presets", style = MaterialTheme.typography.titleMedium)
Text(
text = "Select one or more presets to scan",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = CHIP_SPACING),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING),
verticalArrangement = Arrangement.spacedBy(CHIP_SPACING),
modifier = Modifier.fillMaxWidth(),
) {
ChannelOption.entries.forEach { preset ->
val selected = preset in selectedPresets
val isHome = preset == homePreset
FilterChip(
selected = selected,
onClick = { onTogglePreset(preset) },
label = { Text(if (isHome) "${preset.displayName()} (Home)" else preset.displayName()) },
enabled = enabled,
leadingIcon =
if (selected) {
{
Icon(
imageVector = MeshtasticIcons.Check,
contentDescription = null,
modifier = Modifier.size(FilterChipDefaults.IconSize),
)
}
} else {
null
},
)
}
}
}
}
}

View File

@@ -0,0 +1,139 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.feature.discovery.ui.StatRow
import org.meshtastic.feature.discovery.ui.formatDuration
@Composable
fun PresetResultCard(
result: DiscoveryPresetResultEntity,
@Suppress("UnusedParameter") nodes: List<DiscoveredNodeEntity>,
aiSummary: String? = null,
modifier: Modifier = Modifier,
) {
Card(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp)) {
PresetHeader(result = result)
Spacer(modifier = Modifier.height(12.dp))
StatsGrid(result = result)
Spacer(modifier = Modifier.height(8.dp))
NodeBreakdown(result = result)
Spacer(modifier = Modifier.height(8.dp))
MessageBreakdown(result = result)
// Per-preset AI summary
val summaryText = aiSummary ?: result.aiSummary
if (!summaryText.isNullOrBlank()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = summaryText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (result.numPacketsTx > 0) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
RfHealthSection(result = result)
}
}
}
}
@Composable
private fun PresetHeader(result: DiscoveryPresetResultEntity) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = result.presetName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
text = formatDuration(result.dwellDurationSeconds),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun StatsGrid(result: DiscoveryPresetResultEntity) {
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
StatRow(label = "Unique nodes", value = result.uniqueNodes.toString())
StatRow(
label = "Avg channel utilization",
value = "${NumberFormatter.format(result.avgChannelUtilization, 1)}%",
)
StatRow(label = "Avg airtime rate", value = "${NumberFormatter.format(result.avgAirtimeRate, 1)}%")
}
}
@Composable
private fun NodeBreakdown(result: DiscoveryPresetResultEntity) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MetricChip(label = "Direct", value = result.directNeighborCount.toString(), modifier = Modifier.weight(1f))
MetricChip(label = "Mesh", value = result.meshNeighborCount.toString(), modifier = Modifier.weight(1f))
}
}
@Composable
private fun MessageBreakdown(result: DiscoveryPresetResultEntity) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
MetricChip(label = "Messages", value = result.messageCount.toString(), modifier = Modifier.weight(1f))
MetricChip(label = "Sensor pkts", value = result.sensorPacketCount.toString(), modifier = Modifier.weight(1f))
}
}
@Composable
private fun MetricChip(label: String, value: String, modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = value, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery.ui.component
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import org.meshtastic.core.common.util.NumberFormatter
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.feature.discovery.ui.StatRow
@Composable
fun RfHealthSection(result: DiscoveryPresetResultEntity, modifier: Modifier = Modifier) {
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(text = "RF Health", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
StatRow(label = "Packets TX", value = result.numPacketsTx.toString())
StatRow(label = "Packets RX", value = result.numPacketsRx.toString())
StatRow(label = "Bad packets", value = result.numPacketsRxBad.toString())
StatRow(label = "Duplicate packets", value = result.numRxDupe.toString())
StatRow(label = "Success rate", value = "${NumberFormatter.format(result.packetSuccessRate, 1)}%")
StatRow(label = "Failure rate", value = "${NumberFormatter.format(result.packetFailureRate, 1)}%")
if (result.numOnlineNodes > 0 || result.numTotalNodes > 0) {
StatRow(label = "Online / Total nodes", value = "${result.numOnlineNodes} / ${result.numTotalNodes}")
}
}
}

View File

@@ -0,0 +1,480 @@
/*
* 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/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.database.dao.DiscoveryDao
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.model.ChannelOption
import org.meshtastic.core.model.ConnectionState
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.DiscoveryPacketCollector
import org.meshtastic.core.repository.DiscoveryPacketCollectorRegistry
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeRadioConfigRepository
import org.meshtastic.core.testing.FakeRadioController
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.feature.discovery.ai.DiscoverySummaryAiProvider
import org.meshtastic.proto.Config
import org.meshtastic.proto.Data
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalStats
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
// region Inline fakes
/** In-memory fake of [DiscoveryDao] for unit tests. */
private class FakeDiscoveryDao : DiscoveryDao {
private var nextSessionId = 1L
private var nextPresetResultId = 1L
private var nextNodeId = 1L
val sessions = mutableMapOf<Long, DiscoverySessionEntity>()
val presetResults = mutableMapOf<Long, DiscoveryPresetResultEntity>()
val discoveredNodes = mutableMapOf<Long, DiscoveredNodeEntity>()
override suspend fun insertSession(session: DiscoverySessionEntity): Long {
val id = nextSessionId++
sessions[id] = session.copy(id = id)
return id
}
override suspend fun updateSession(session: DiscoverySessionEntity) {
sessions[session.id] = session
}
override fun getAllSessions(): Flow<List<DiscoverySessionEntity>> =
flowOf(sessions.values.sortedByDescending { it.timestamp })
override suspend fun getSession(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId]
override fun getSessionFlow(sessionId: Long): Flow<DiscoverySessionEntity?> = MutableStateFlow(sessions[sessionId])
override suspend fun deleteSession(sessionId: Long) {
sessions.remove(sessionId)
val resultIds = presetResults.values.filter { it.sessionId == sessionId }.map { it.id }
resultIds.forEach { resultId ->
discoveredNodes.entries.removeAll { it.value.presetResultId == resultId }
presetResults.remove(resultId)
}
}
override suspend fun insertPresetResult(result: DiscoveryPresetResultEntity): Long {
val id = nextPresetResultId++
presetResults[id] = result.copy(id = id)
return id
}
override suspend fun updatePresetResult(result: DiscoveryPresetResultEntity) {
presetResults[result.id] = result
}
override suspend fun getPresetResults(sessionId: Long): List<DiscoveryPresetResultEntity> =
presetResults.values.filter { it.sessionId == sessionId }
override fun getPresetResultsFlow(sessionId: Long): Flow<List<DiscoveryPresetResultEntity>> =
flowOf(getPresetResultsSynchronous(sessionId))
private fun getPresetResultsSynchronous(sessionId: Long): List<DiscoveryPresetResultEntity> =
presetResults.values.filter { it.sessionId == sessionId }
override suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long {
val id = nextNodeId++
discoveredNodes[id] = node.copy(id = id)
return id
}
override suspend fun insertDiscoveredNodes(nodes: List<DiscoveredNodeEntity>) {
nodes.forEach { insertDiscoveredNode(it) }
}
override suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity) {
discoveredNodes[node.id] = node
}
override suspend fun getDiscoveredNodes(presetResultId: Long): List<DiscoveredNodeEntity> =
discoveredNodes.values.filter { it.presetResultId == presetResultId }
override fun getDiscoveredNodesFlow(presetResultId: Long): Flow<List<DiscoveredNodeEntity>> =
flowOf(discoveredNodes.values.filter { it.presetResultId == presetResultId })
override suspend fun getUniqueNodeNums(sessionId: Long): List<Long> = presetResults.values
.filter { it.sessionId == sessionId }
.flatMap { pr -> discoveredNodes.values.filter { it.presetResultId == pr.id } }
.map { it.nodeNum }
.distinct()
override suspend fun getUniqueNodeCount(sessionId: Long): Int = getUniqueNodeNums(sessionId).size
override suspend fun getSessionWithResults(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId]
}
/** Simple fake collector registry that tracks registration. */
private class FakeCollectorRegistry : DiscoveryPacketCollectorRegistry {
override var collector: DiscoveryPacketCollector? = null
}
/** AI provider that is never available (no AI in tests). */
private class FakeAiProvider : DiscoverySummaryAiProvider {
override val isAvailable: Boolean = false
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String? = null
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? = null
}
// endregion
class DiscoveryScanEngineTest {
private val radioController = FakeRadioController()
private val serviceRepository = FakeServiceRepository().apply { setConnectionState(ConnectionState.Connected) }
private val nodeRepository = FakeNodeRepository()
private val radioConfigRepository =
FakeRadioConfigRepository().apply {
setLocalConfigDirect(
LocalConfig(lora = Config.LoRaConfig(modem_preset = ChannelOption.LONG_FAST.modemPreset)),
)
}
private val collectorRegistry = FakeCollectorRegistry()
private val discoveryDao = FakeDiscoveryDao()
private val aiProvider = FakeAiProvider()
private val engine =
DiscoveryScanEngine(
radioController = radioController,
serviceRepository = serviceRepository,
nodeRepository = nodeRepository,
radioConfigRepository = radioConfigRepository,
collectorRegistry = collectorRegistry,
discoveryDao = discoveryDao,
aiProvider = aiProvider,
)
private val testPresets = listOf(ChannelOption.LONG_FAST)
/**
* After [DiscoveryScanEngine.startScan], the state is set to [DiscoveryScanState.Shifting] synchronously. This
* helper asserts that the engine is active — no real-time wait needed.
*/
private fun assertScanActive() {
assertTrue(engine.isActive, "Engine should be active after startScan")
}
/**
* Waits briefly for the scan loop (running on [ioDispatcher]) to complete its per-preset initialization (collection
* clearing). Call before sending packets to avoid a race where the scan loop's `collectedNodes.clear()` wipes out
* test-injected data.
*/
@Suppress("MagicNumber")
private fun awaitScanLoopInit() {
Thread.sleep(500)
}
// region Helper factories
private fun createMyNodeInfo(nodeNum: Int = 1000) = MyNodeInfo(
myNodeNum = nodeNum,
hasGPS = true,
model = "TestModel",
firmwareVersion = "2.0.0",
couldUpdate = false,
shouldUpdate = false,
currentPacketId = 1L,
messageTimeoutMsec = 5000,
minAppVersion = 1,
maxChannels = 8,
hasWifi = false,
channelUtilization = 0f,
airUtilTx = 0f,
deviceId = "test-device",
)
private fun createNodeWithPosition(num: Int, latI: Int = 0, lonI: Int = 0) = Node(
num = num,
user = User(id = "!${num.toString(16)}", short_name = "T$num", long_name = "Test Node $num"),
position = Position(latitude_i = latI, longitude_i = lonI),
)
private fun createPositionMeshPacket(
from: Int,
latI: Int,
lonI: Int,
snr: Float = 5.5f,
rssi: Int = -70,
): MeshPacket {
val posPayload = Position.ADAPTER.encode(Position(latitude_i = latI, longitude_i = lonI)).toByteString()
val data = Data(portnum = PortNum.POSITION_APP, payload = posPayload)
return MeshPacket(from = from, decoded = data, rx_snr = snr, rx_rssi = rssi)
}
private fun createTelemetryWithLocalStats(from: Int, localStats: LocalStats): MeshPacket {
val telPayload = Telemetry.ADAPTER.encode(Telemetry(local_stats = localStats)).toByteString()
val data = Data(portnum = PortNum.TELEMETRY_APP, payload = telPayload)
return MeshPacket(from = from, decoded = data)
}
private fun createDataPacket(from: Int): DataPacket = DataPacket(
to = DataPacket.ID_BROADCAST,
bytes = ByteString.EMPTY,
dataType = PortNum.POSITION_APP.value,
from = "!${from.toString(16)}",
hopStart = 3,
hopLimit = 3,
)
// endregion
@Test
fun startScanCreatesSessionAndRegistersCollector() = runTest {
engine.startScan(testPresets, dwellDurationSeconds = 10)
// Session should be persisted (happens synchronously inside startScan)
assertEquals(1, discoveryDao.sessions.size)
val session = discoveryDao.sessions.values.first()
assertEquals("in_progress", session.completionStatus)
assertEquals("LONG_FAST", session.presetsScanned)
assertEquals("LONG_FAST", session.homePreset)
// Collector should be registered (synchronous inside startScan)
assertNotNull(collectorRegistry.collector)
assertTrue(collectorRegistry.collector === engine)
// currentSession should be populated
val currentSession = engine.currentSession.value
assertNotNull(currentSession)
assertEquals(session.id, currentSession.id)
// Wait for scan loop to start then clean up
assertScanActive()
engine.stopScan()
}
@Test
fun stopScanPersistsResultsAndTransitionsToIdle() = runTest {
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
// Verify scan is active
assertTrue(engine.isActive)
engine.stopScan()
// State should be Idle
assertTrue(engine.scanState.value is DiscoveryScanState.Idle)
assertFalse(engine.isActive)
// Collector should be unregistered
assertNull(collectorRegistry.collector)
// Session should be finalized with "stopped" status
val session = discoveryDao.sessions.values.first()
assertEquals("stopped", session.completionStatus)
}
@Test
fun completeScanCreatesSessionWithInProgressStatus() = runTest {
engine.startScan(testPresets, dwellDurationSeconds = 5)
// Immediately after startScan, the session should exist with "in_progress"
val session = discoveryDao.sessions.values.first()
assertEquals("in_progress", session.completionStatus)
// Wait for the scan loop to start, then verify active
assertScanActive()
assertTrue(engine.isActive)
engine.stopScan()
}
@Test
fun emptyPresetDwellPersistsZeroResultEntry() = runTest {
engine.startScan(testPresets, dwellDurationSeconds = 10)
assertScanActive()
// Stop without receiving any packets — forces persistCurrentDwellResults
engine.stopScan()
// Should have a preset result with zero unique nodes
val presetResults = discoveryDao.presetResults.values.toList()
assertTrue(presetResults.isNotEmpty(), "Expected at least one preset result")
val result = presetResults.first()
assertEquals("LONG_FAST", result.presetName)
assertEquals(0, result.uniqueNodes)
assertEquals(0, result.messageCount)
// No discovered nodes
assertTrue(discoveryDao.discoveredNodes.isEmpty())
}
@Test
fun packetCollectionPopulatesNodeData() = runTest {
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
awaitScanLoopInit()
// Simulate receiving a position packet
val meshPacket =
createPositionMeshPacket(from = 12345, latI = 377749300, lonI = -1224194200, snr = 5.5f, rssi = -70)
val dataPacket = createDataPacket(from = 12345)
engine.onPacketReceived(meshPacket, dataPacket)
// Stop scan to persist results
engine.stopScan()
// Should have one discovered node with lat/lon
val nodes = discoveryDao.discoveredNodes.values.toList()
assertEquals(1, nodes.size)
val node = nodes.first()
assertEquals(12345L, node.nodeNum)
assertNotNull(node.latitude, "Node should have latitude")
assertNotNull(node.longitude, "Node should have longitude")
// latitude_i = 377749300 → 37.77493
assertTrue(node.latitude!! > 37.7 && node.latitude!! < 37.8, "Latitude should be ~37.77")
// longitude_i = -1224194200 → -122.41942
assertTrue(node.longitude!! < -122.4 && node.longitude!! > -122.5, "Longitude should be ~-122.42")
assertEquals(5.5f, node.snr)
assertEquals(-70, node.rssi)
}
@Test
fun telemetryWithLocalStatsPopulatesRfHealth() = runTest {
nodeRepository.setMyNodeInfo(createMyNodeInfo())
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
awaitScanLoopInit()
// Send a telemetry packet with local_stats
val localStats =
LocalStats(
num_packets_tx = 100,
num_packets_rx = 200,
num_packets_rx_bad = 5,
num_rx_dupe = 10,
num_tx_relay = 15,
num_tx_relay_canceled = 2,
num_online_nodes = 3,
num_total_nodes = 10,
uptime_seconds = 3600,
)
val meshPacket = createTelemetryWithLocalStats(from = 12345, localStats = localStats)
val dataPacket = createDataPacket(from = 12345)
engine.onPacketReceived(meshPacket, dataPacket)
// Stop to persist
engine.stopScan()
// The preset result should have RF health fields from local_stats
val presetResults = discoveryDao.presetResults.values.toList()
assertTrue(presetResults.isNotEmpty())
val result = presetResults.first()
assertEquals(100, result.numPacketsTx)
assertEquals(200, result.numPacketsRx)
assertEquals(5, result.numPacketsRxBad)
assertEquals(10, result.numRxDupe)
assertEquals(15, result.numTxRelay)
assertEquals(2, result.numTxRelayCanceled)
assertEquals(3, result.numOnlineNodes)
assertEquals(10, result.numTotalNodes)
assertEquals(3600, result.uptimeSeconds)
// Packet success/failure rates should be computed
// success = (200 - 5) / 200 * 100 = 97.5
// failure = 5 / 200 * 100 = 2.5
assertTrue(result.packetSuccessRate > 97.0, "Success rate should be ~97.5%")
assertTrue(result.packetFailureRate > 2.0, "Failure rate should be ~2.5%")
}
@Test
fun userPositionCapturedAtScanStart() = runTest {
val myNodeNum = 1000
nodeRepository.setMyNodeInfo(createMyNodeInfo(myNodeNum))
nodeRepository.setNodes(listOf(createNodeWithPosition(num = myNodeNum, latI = 377749300, lonI = -1224194200)))
engine.startScan(testPresets, dwellDurationSeconds = 10)
val session = discoveryDao.sessions.values.first()
// User position should be captured from the own node
// latitude_i = 377749300 → 37.77493
assertTrue(session.userLatitude > 37.7 && session.userLatitude < 37.8, "User lat should be ~37.77")
assertTrue(session.userLongitude < -122.4 && session.userLongitude > -122.5, "User lon should be ~-122.42")
engine.stopScan()
}
@Test
fun distanceFromUserCalculatedForDiscoveredNodes() = runTest {
val myNodeNum = 1000
nodeRepository.setMyNodeInfo(createMyNodeInfo(myNodeNum))
// User at San Francisco (37.7749, -122.4194)
nodeRepository.setNodes(listOf(createNodeWithPosition(num = myNodeNum, latI = 377749000, lonI = -1224194000)))
engine.startScan(testPresets, dwellDurationSeconds = 60)
assertScanActive()
awaitScanLoopInit()
// Discovered node at Oakland (37.8044, -122.2712) — roughly 15 km away
val meshPacket = createPositionMeshPacket(from = 54321, latI = 378044000, lonI = -1222712000)
val dataPacket = createDataPacket(from = 54321)
engine.onPacketReceived(meshPacket, dataPacket)
engine.stopScan()
val nodes = discoveryDao.discoveredNodes.values.toList()
assertEquals(1, nodes.size)
val node = nodes.first()
assertNotNull(node.distanceFromUser, "Distance from user should be computed")
// SF to Oakland is roughly 1317 km
assertTrue(
node.distanceFromUser!! > 10_000 && node.distanceFromUser!! < 25_000,
"Distance should be between 10km and 25km, was ${node.distanceFromUser}m",
)
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.discovery.ai
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.core.database.entity.DiscoverySessionEntity
import org.meshtastic.feature.discovery.DiscoverySummaryGenerator
/** JVM/Desktop fallback that delegates to the algorithmic [DiscoverySummaryGenerator]. */
@Single(binds = [DiscoverySummaryAiProvider::class])
class AlgorithmicSummaryProvider(private val generator: DiscoverySummaryGenerator) : DiscoverySummaryAiProvider {
override val isAvailable: Boolean = true
override suspend fun generateSessionSummary(
session: DiscoverySessionEntity,
presetResults: List<DiscoveryPresetResultEntity>,
): String = generator.generateSessionSummary(session, presetResults)
override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String =
generator.generatePresetSummary(result)
}

View File

@@ -0,0 +1,76 @@
/*
* 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.discovery.export
import org.koin.core.annotation.Single
private const val SEPARATOR_LENGTH = 60
@Single
class TextDiscoveryExporter : DiscoveryExporter {
@Suppress("TooGenericExceptionCaught")
override suspend fun export(data: DiscoveryExportData): ExportResult = try {
val text = renderText(data)
val fileName = DiscoveryReportFormatter.generateFileName(data.session, "txt")
ExportResult.Success(content = text.encodeToByteArray(), mimeType = "text/plain", fileName = fileName)
} catch (e: Exception) {
ExportResult.Error("Text export failed: ${e.message}")
}
private fun renderText(data: DiscoveryExportData): String = buildString {
appendLine("MESHTASTIC DISCOVERY REPORT")
appendLine("=".repeat(SEPARATOR_LENGTH))
appendLine()
appendLine("SESSION OVERVIEW")
appendLine("-".repeat(SEPARATOR_LENGTH))
for ((label, value) in DiscoveryReportFormatter.formatSessionOverviewLines(data.session)) {
appendLine(" $label: $value")
}
appendLine()
for (result in data.presetResults) {
appendLine("PRESET: ${result.presetName}")
appendLine("-".repeat(SEPARATOR_LENGTH))
for ((label, value) in DiscoveryReportFormatter.formatPresetLines(result)) {
appendLine(" $label: $value")
}
val nodes = data.nodesByPreset[result.id].orEmpty()
if (nodes.isNotEmpty()) {
appendLine()
appendLine(" Discovered Nodes (${nodes.size}):")
for (node in nodes) {
appendLine(" ${DiscoveryReportFormatter.formatNodeLine(node)}")
}
}
appendLine()
}
val summary = data.session.aiSummary
if (!summary.isNullOrBlank()) {
appendLine("AI ANALYSIS")
appendLine("-".repeat(SEPARATOR_LENGTH))
appendLine(summary)
appendLine()
}
appendLine("=".repeat(SEPARATOR_LENGTH))
appendLine("Generated by Meshtastic")
}
}

View File

@@ -40,6 +40,7 @@ import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import org.meshtastic.core.navigation.DiscoveryRoute
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.WifiProvisionRoute
@@ -56,6 +57,7 @@ import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.MeshtasticDialog
import org.meshtastic.core.ui.icon.FilterList
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PermScanWifi
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.feature.settings.component.AppInfoSection
import org.meshtastic.feature.settings.component.AppearanceSection
@@ -238,6 +240,12 @@ fun SettingsScreen(
onShowThemePicker = { showThemePickerDialog = true },
)
ExpressiveSection(title = "Local Mesh Discovery") {
ListItem(text = "Local Mesh Discovery", leadingIcon = MeshtasticIcons.PermScanWifi) {
onNavigate(DiscoveryRoute.DiscoveryGraph)
}
}
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())

View File

@@ -38,6 +38,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.meshtastic.core.database.DatabaseConstants
import org.meshtastic.core.navigation.DiscoveryRoute
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.navigation.SettingsRoute
import org.meshtastic.core.navigation.WifiProvisionRoute
@@ -65,6 +66,7 @@ import org.meshtastic.core.ui.icon.Info
import org.meshtastic.core.ui.icon.Language
import org.meshtastic.core.ui.icon.Memory
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.PermScanWifi
import org.meshtastic.core.ui.icon.Wifi
import org.meshtastic.core.ui.util.rememberShowToastResource
import org.meshtastic.feature.settings.component.ExpressiveSection
@@ -200,6 +202,12 @@ fun DesktopSettingsScreen(
)
}
ExpressiveSection(title = "Local Mesh Discovery") {
ListItem(text = "Local Mesh Discovery", leadingIcon = MeshtasticIcons.PermScanWifi) {
onNavigate(DiscoveryRoute.DiscoveryGraph)
}
}
ExpressiveSection(title = stringResource(Res.string.wifi_devices)) {
ListItem(text = stringResource(Res.string.wifi_devices), leadingIcon = MeshtasticIcons.Wifi) {
onNavigate(WifiProvisionRoute.WifiProvision())

View File

@@ -109,6 +109,7 @@ include(
":feature:map",
":feature:node",
":feature:settings",
":feature:discovery",
":feature:firmware",
":feature:wifi-provision",
":desktop",