mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 16:55:02 -04:00
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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
app/src/main/res/drawable/ic_person.xml
Normal file
9
app/src/main/res/drawable/ic_person.xml
Normal 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>
|
||||
9
app/src/main/res/drawable/ic_thermostat.xml
Normal file
9
app/src/main/res/drawable/ic_thermostat.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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> =
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,10 +17,13 @@
|
||||
package org.meshtastic.core.database.di
|
||||
|
||||
import org.koin.core.annotation.ComponentScan
|
||||
import org.koin.core.annotation.Factory
|
||||
import org.koin.core.annotation.Module
|
||||
import org.koin.core.annotation.Named
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.core.database.DatabaseProvider
|
||||
import org.meshtastic.core.database.createDatabaseDataStore
|
||||
import org.meshtastic.core.database.dao.DiscoveryDao
|
||||
|
||||
@Module
|
||||
@ComponentScan("org.meshtastic.core.database")
|
||||
@@ -28,4 +31,8 @@ class CoreDatabaseModule {
|
||||
@Single
|
||||
@Named("DatabaseDataStore")
|
||||
fun provideDatabaseDataStore() = createDatabaseDataStore("db-manager-prefs")
|
||||
|
||||
@Factory
|
||||
fun provideDiscoveryDao(databaseProvider: DatabaseProvider): DiscoveryDao =
|
||||
databaseProvider.currentDb.value.discoveryDao()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -40,6 +40,7 @@ val MeshtasticNavSavedStateConfig = SavedStateConfiguration {
|
||||
subclassesOfSealed<SettingsRoute>()
|
||||
subclassesOfSealed<FirmwareRoute>()
|
||||
subclassesOfSealed<WifiProvisionRoute>()
|
||||
subclassesOfSealed<DiscoveryRoute>()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -201,6 +201,14 @@ object StatusColors {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
object DiscoveryMapColors {
|
||||
val DirectNode = Color(0xFF4CAF50)
|
||||
val MeshNode = Color(0xFF2196F3)
|
||||
val UserPosition = Color(0xFFFF9800)
|
||||
val DirectLine = Color(0x804CAF50)
|
||||
}
|
||||
|
||||
object MessageItemColors {
|
||||
val Red = Color(0x4DFF0000)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
/** Neighbor type classification for discovery map markers. */
|
||||
enum class DiscoveryNeighborType {
|
||||
DIRECT,
|
||||
MESH,
|
||||
}
|
||||
|
||||
/**
|
||||
* Platform-neutral representation of a discovered node for map rendering. Contains only the data needed to place and
|
||||
* style a marker — no Room entities or platform types leak into the map provider API.
|
||||
*/
|
||||
data class DiscoveryMapNode(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
val shortName: String?,
|
||||
val longName: String?,
|
||||
val neighborType: DiscoveryNeighborType,
|
||||
val snr: Float = 0f,
|
||||
val rssi: Int = 0,
|
||||
val messageCount: Int = 0,
|
||||
val sensorPacketCount: Int = 0,
|
||||
) {
|
||||
/**
|
||||
* FR-011: Map icon classification. If environment packets > text messages, return true (sensor). Otherwise return
|
||||
* false (social/chat).
|
||||
*/
|
||||
val isSensorNode: Boolean
|
||||
get() = sensorPacketCount > messageCount
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Meshtastic LLC
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.ui.util
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import org.meshtastic.core.ui.component.PlaceholderScreen
|
||||
|
||||
/**
|
||||
* Provides an embeddable discovery map composable that renders discovered node markers and topology polylines for a
|
||||
* Local Mesh Discovery scan session. Unlike [LocalMapViewProvider], this does **not** include node clustering,
|
||||
* waypoints, location tracking, or any main-map features — it is designed to be embedded inside the discovery summary
|
||||
* scaffold.
|
||||
*
|
||||
* Parameters:
|
||||
* - `userLatitude` / `userLongitude`: The scanner's position at scan time (orange marker).
|
||||
* - `nodes`: Platform-neutral [DiscoveryMapNode] list for marker placement and styling.
|
||||
* - `modifier`: Compose modifier for the map.
|
||||
*
|
||||
* On Desktop/JVM targets where native maps are not yet available, it falls back to a [PlaceholderScreen].
|
||||
*/
|
||||
@Suppress("Wrapping")
|
||||
val LocalDiscoveryMapProvider =
|
||||
compositionLocalOf<
|
||||
@Composable (
|
||||
userLatitude: Double,
|
||||
userLongitude: Double,
|
||||
nodes: List<DiscoveryMapNode>,
|
||||
modifier: Modifier,
|
||||
) -> Unit,
|
||||
> {
|
||||
{ _, _, _, _ -> PlaceholderScreen("Discovery Map") }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
55
feature/discovery/build.gradle.kts
Normal file
55
feature/discovery/build.gradle.kts
Normal 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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')}"
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 13–17 km
|
||||
assertTrue(
|
||||
node.distanceFromUser!! > 10_000 && node.distanceFromUser!! < 25_000,
|
||||
"Distance should be between 10km and 25km, was ${node.distanceFromUser}m",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -109,6 +109,7 @@ include(
|
||||
":feature:map",
|
||||
":feature:node",
|
||||
":feature:settings",
|
||||
":feature:discovery",
|
||||
":feature:firmware",
|
||||
":feature:wifi-provision",
|
||||
":desktop",
|
||||
|
||||
Reference in New Issue
Block a user