diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45de5390d..831e64465 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt new file mode 100644 index 000000000..bc5c4ec59 --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt @@ -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 . + */ +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, + modifier: Modifier = Modifier, +) { + DiscoveryOsmMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier) +} diff --git a/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt new file mode 100644 index 000000000..8b1692bc1 --- /dev/null +++ b/app/src/fdroid/kotlin/org/meshtastic/app/map/discovery/DiscoveryOsmMap.kt @@ -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 . + */ +@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, + 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() + }, + ) +} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt new file mode 100644 index 000000000..d474a4f73 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryGoogleMap.kt @@ -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 . + */ +@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, + 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, + ) + } + } + } +} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt new file mode 100644 index 000000000..9dff45053 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMap.kt @@ -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 . + */ +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, + modifier: Modifier = Modifier, +) { + DiscoveryGoogleMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier) +} diff --git a/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt new file mode 100644 index 000000000..f1eaea766 --- /dev/null +++ b/app/src/google/kotlin/org/meshtastic/app/map/discovery/DiscoveryMarkerChip.kt @@ -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 . + */ +@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) + } + } +} diff --git a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt index 78e8ce559..7b7e2216d 100644 --- a/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt +++ b/app/src/main/kotlin/org/meshtastic/app/MainActivity.kt @@ -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() diff --git a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt index 09f38eaef..0451b4f50 100644 --- a/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt +++ b/app/src/main/kotlin/org/meshtastic/app/di/AppKoinModule.kt @@ -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, diff --git a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt index 46409b14e..08b6b4adb 100644 --- a/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt +++ b/app/src/main/kotlin/org/meshtastic/app/ui/Main.kt @@ -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) diff --git a/app/src/main/res/drawable/ic_person.xml b/app/src/main/res/drawable/ic_person.xml new file mode 100644 index 000000000..8e5be7ed1 --- /dev/null +++ b/app/src/main/res/drawable/ic_person.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_thermostat.xml b/app/src/main/res/drawable/ic_thermostat.xml new file mode 100644 index 000000000..5257f7fe6 --- /dev/null +++ b/app/src/main/res/drawable/ic_thermostat.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt index 1fd4b39ce..4f265c5fa 100644 --- a/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt +++ b/app/src/test/kotlin/org/meshtastic/app/ui/NavigationAssemblyTest.kt @@ -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) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt new file mode 100644 index 000000000..0ed518ec0 --- /dev/null +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/DiscoveryPacketCollectorRegistryImpl.kt @@ -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 . + */ +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 +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt index fa935473a..dd66a84d8 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshDataHandlerImpl.kt @@ -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( diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json new file mode 100644 index 000000000..0d6f55c07 --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/39.json @@ -0,0 +1,1478 @@ +{ + "formatVersion": 1, + "database": { + "version": 39, + "identityHash": "e39ee4f34ed8da08f3cb21bfd4a5165c", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "discovery_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetsScanned", + "columnName": "presets_scanned", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homePreset", + "columnName": "home_preset", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalUniqueNodes", + "columnName": "total_unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalMessages", + "columnName": "total_messages", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalSensorPackets", + "columnName": "total_sensor_packets", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "furthestNodeDistance", + "columnName": "furthest_node_distance", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "completionStatus", + "columnName": "completion_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'complete'" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "userLatitude", + "columnName": "user_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "userLongitude", + "columnName": "user_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalDwellSeconds", + "columnName": "total_dwell_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "discovery_preset_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetName", + "columnName": "preset_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dwellDurationSeconds", + "columnName": "dwell_duration_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uniqueNodes", + "columnName": "unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directNeighborCount", + "columnName": "direct_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "meshNeighborCount", + "columnName": "mesh_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "avgAirtimeRate", + "columnName": "avg_airtime_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetSuccessRate", + "columnName": "packet_success_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetFailureRate", + "columnName": "packet_failure_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "numPacketsTx", + "columnName": "num_packets_tx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRx", + "columnName": "num_packets_rx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRxBad", + "columnName": "num_packets_rx_bad", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numRxDupe", + "columnName": "num_rx_dupe", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelay", + "columnName": "num_tx_relay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelayCanceled", + "columnName": "num_tx_relay_canceled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numOnlineNodes", + "columnName": "num_online_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTotalNodes", + "columnName": "num_total_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uptimeSeconds", + "columnName": "uptime_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovery_preset_result_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_session", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discovered_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetResultId", + "columnName": "preset_result_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "neighborType", + "columnName": "neighbor_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'direct'" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromUser", + "columnName": "distance_from_user", + "affinity": "REAL" + }, + { + "fieldPath": "hopCount", + "columnName": "hop_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovered_node_preset_result_id", + "unique": false, + "columnNames": [ + "preset_result_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)" + }, + { + "name": "index_discovered_node_node_num", + "unique": false, + "columnNames": [ + "node_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_preset_result", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "preset_result_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e39ee4f34ed8da08f3cb21bfd4a5165c')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index d329d184c..bcfbe8a26 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -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 RoomDatabase.Builder.configureCommon(): RoomDatabase.Builder = diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt new file mode 100644 index 000000000..dbf59a88b --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/DiscoveryDao.kt @@ -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 . + */ +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> + + @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 + + @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 + + @Query("SELECT * FROM discovery_preset_result WHERE session_id = :sessionId") + fun getPresetResultsFlow(sessionId: Long): Flow> + + // endregion + + // region Discovered node operations + + @Insert suspend fun insertDiscoveredNode(node: DiscoveredNodeEntity): Long + + @Insert suspend fun insertDiscoveredNodes(nodes: List) + + @Update suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity) + + @Query("SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId") + suspend fun getDiscoveredNodes(presetResultId: Long): List + + @Query("SELECT * FROM discovered_node WHERE preset_result_id = :presetResultId") + fun getDiscoveredNodesFlow(presetResultId: Long): Flow> + + @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 + + // 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 +} diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt index acae365da..4328cfe6e 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/di/CoreDatabaseModule.kt @@ -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() } diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt new file mode 100644 index 000000000..ca22fe775 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveredNodeEntity.kt @@ -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 . + */ +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, +) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt new file mode 100644 index 000000000..e7fe4fd10 --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoveryPresetResultEntity.kt @@ -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 . + */ +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, +) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt new file mode 100644 index 000000000..b480b826e --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/DiscoverySessionEntity.kt @@ -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 . + */ +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, +) diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt index 146381c9d..ac2286a43 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/NavigationConfig.kt @@ -40,6 +40,7 @@ val MeshtasticNavSavedStateConfig = SavedStateConfiguration { subclassesOfSealed() subclassesOfSealed() subclassesOfSealed() + subclassesOfSealed() } } } diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 418ddd58a..030d8686c 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -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 +} diff --git a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt index 95512ecf4..9419862d0 100644 --- a/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt +++ b/core/network/src/commonMain/kotlin/org/meshtastic/core/network/radio/BleRadioTransport.kt @@ -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() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt new file mode 100644 index 000000000..f0ac50553 --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollector.kt @@ -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 . + */ +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) +} diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt new file mode 100644 index 000000000..704713b0f --- /dev/null +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/DiscoveryPacketCollectorRegistry.kt @@ -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 . + */ +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? +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt index 9304d5e2b..1f8081e6c 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt @@ -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) } diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt new file mode 100644 index 000000000..e1b5352b0 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/DiscoveryMapNode.kt @@ -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 . + */ +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 +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDiscoveryMapProvider.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDiscoveryMapProvider.kt new file mode 100644 index 000000000..37651a6ac --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/LocalDiscoveryMapProvider.kt @@ -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 . + */ +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, + modifier: Modifier, + ) -> Unit, + > { + { _, _, _, _ -> PlaceholderScreen("Discovery Map") } + } diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts index 4fa09179f..fcaba82ea 100644 --- a/desktop/build.gradle.kts +++ b/desktop/build.gradle.kts @@ -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) diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt index 59f468f82..97913af23 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/di/DesktopKoinModule.kt @@ -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(), diff --git a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt index d7581cc9c..108fff3d1 100644 --- a/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt +++ b/desktop/src/main/kotlin/org/meshtastic/desktop/navigation/DesktopNavigation.kt @@ -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.desktopNavGraph( settingsGraph(backStack) channelsGraph(backStack) connectionsGraph(backStack) + discoveryGraph(backStack) wifiProvisionGraph(backStack) } diff --git a/feature/discovery/build.gradle.kts b/feature/discovery/build.gradle.kts new file mode 100644 index 000000000..02351a3f1 --- /dev/null +++ b/feature/discovery/build.gradle.kts @@ -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 . + */ + +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) } + } +} diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/.gitkeep b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt new file mode 100644 index 000000000..3406db489 --- /dev/null +++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/ai/GeminiNanoSummaryProvider.kt @@ -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 . + */ +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, + ): String = generator.generateSessionSummary(session, presetResults) + + override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String = + generator.generatePresetSummary(result) +} diff --git a/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt new file mode 100644 index 000000000..3b4445e6e --- /dev/null +++ b/feature/discovery/src/androidMain/kotlin/org/meshtastic/feature/discovery/export/PdfDiscoveryExporter.kt @@ -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 . + */ +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 + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt new file mode 100644 index 000000000..75b98d826 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryDetailViewModel.kt @@ -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 . + */ +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 = + discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null) + + val presetResults: StateFlow> = + discoveryDao.getPresetResultsFlow(sessionId).stateInWhileSubscribed(initialValue = emptyList()) + + private val _nodesByPreset = MutableStateFlow>>(emptyMap()) + val nodesByPreset: StateFlow>> = _nodesByPreset.asStateFlow() + + init { + loadNodes() + } + + private fun loadNodes() { + safeLaunch(tag = "loadNodes") { + val results = discoveryDao.getPresetResults(sessionId) + val nodesMap = mutableMapOf>() + for (result in results) { + nodesMap[result.id] = discoveryDao.getDiscoveredNodes(result.id) + } + _nodesByPreset.value = nodesMap + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryViewModel.kt new file mode 100644 index 000000000..c031e59c7 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryHistoryViewModel.kt @@ -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 . + */ +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> = + discoveryDao.getAllSessions().stateInWhileSubscribed(initialValue = emptyList()) + + fun deleteSession(sessionId: Long) { + safeLaunch(tag = "deleteSession") { discoveryDao.deleteSession(sessionId) } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryMapViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryMapViewModel.kt new file mode 100644 index 000000000..3f88bf7bf --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryMapViewModel.kt @@ -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 . + */ +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 = + discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null) + + private val _allNodes = MutableStateFlow>(emptyList()) + val allNodes: StateFlow> = _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 + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt new file mode 100644 index 000000000..74421bd3b --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt @@ -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 . + */ +@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.Idle) + val scanState: StateFlow = _scanState.asStateFlow() + + private val _currentSession = MutableStateFlow(null) + val currentSession: StateFlow = _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() + + /** DeviceMetrics entries per node for the 2-packet rule. Keyed by nodeNum. */ + private val deviceMetricsLog = mutableMapOf>() + + 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, 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, 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 { + 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 + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanState.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanState.kt new file mode 100644 index 000000000..0c6bc44cb --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanState.kt @@ -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 . + */ +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 +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryGenerator.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryGenerator.kt new file mode 100644 index 000000000..65c99e41b --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryGenerator.kt @@ -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 . + */ +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, + ): String { + if (presetResults.isEmpty()) return "No presets were scanned during this session." + + val ranked = + presetResults.sortedWith( + compareByDescending { 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): 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, + ): 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): 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): 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 + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryViewModel.kt new file mode 100644 index 000000000..052a2f3d7 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoverySummaryViewModel.kt @@ -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 . + */ +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 = + discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null) + + val presetResults: StateFlow> = + discoveryDao.getPresetResultsFlow(sessionId).stateInWhileSubscribed(initialValue = emptyList()) + + private val _nodesByPreset = MutableStateFlow>>(emptyMap()) + val nodesByPreset: StateFlow>> = _nodesByPreset.asStateFlow() + + private val _algorithmicSummary = MutableStateFlow(null) + val algorithmicSummary: StateFlow = _algorithmicSummary.asStateFlow() + + private val _aiSummary = MutableStateFlow(null) + val aiSummary: StateFlow = _aiSummary.asStateFlow() + + private val _presetAiSummaries = MutableStateFlow>(emptyMap()) + val presetAiSummaries: StateFlow> = _presetAiSummaries.asStateFlow() + + private val _isGeneratingAi = MutableStateFlow(false) + val isGeneratingAi: StateFlow = _isGeneratingAi.asStateFlow() + + private val _exportResult = MutableStateFlow(null) + val exportResult: StateFlow = _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>() + 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) { + 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) { + 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)) + } + } + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt new file mode 100644 index 000000000..5db1f449f --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryViewModel.kt @@ -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 . + */ +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 = scanEngine.scanState + val currentSession: StateFlow = scanEngine.currentSession + val connectionState: StateFlow = serviceRepository.connectionState + + val homePreset: StateFlow = + 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>(emptySet()) + val selectedPresets: StateFlow> = _selectedPresets.asStateFlow() + + private val _dwellDurationMinutes = MutableStateFlow(DEFAULT_DWELL_MINUTES) + val dwellDurationMinutes: StateFlow = _dwellDurationMinutes.asStateFlow() + + val isConnected: StateFlow = + serviceRepository.connectionState + .map { it is ConnectionState.Connected } + .stateInWhileSubscribed(initialValue = false) + + val sessions: StateFlow> = + 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 + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ai/DiscoverySummaryAiProvider.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ai/DiscoverySummaryAiProvider.kt new file mode 100644 index 000000000..116837680 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ai/DiscoverySummaryAiProvider.kt @@ -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 . + */ +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, + ): String? + + /** Generate a per-preset summary. Returns `null` on failure. */ + suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String? +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ai/LoRaPresetReference.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ai/LoRaPresetReference.kt new file mode 100644 index 000000000..3e415f459 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ai/LoRaPresetReference.kt @@ -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 . + */ +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 = buildString { + appendLine("LoRa Preset Reference:") + for (name in presetNames) { + val ref = formatReference(name) + if (ref != null) { + appendLine(" $ref") + } + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/di/FeatureDiscoveryModule.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/di/FeatureDiscoveryModule.kt new file mode 100644 index 000000000..311a7df82 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/di/FeatureDiscoveryModule.kt @@ -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 . + */ +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 diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryExporter.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryExporter.kt new file mode 100644 index 000000000..38223e776 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryExporter.kt @@ -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 . + */ +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, + val nodesByPreset: Map>, +) + +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 +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt new file mode 100644 index 000000000..dc4f4ebf6 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/export/DiscoveryReportFormatter.kt @@ -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 . + */ +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> = 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> = 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" + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt new file mode 100644 index 000000000..1af6652e4 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/navigation/DiscoveryNavigation.kt @@ -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 . + */ +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.discoveryGraph(backStack: NavBackStack) { + entry { DiscoveryScanScreenEntry(backStack) } + entry { DiscoveryScanScreenEntry(backStack) } + entry { route -> + val viewModel = koinViewModel { parametersOf(route.sessionId) } + DiscoverySummaryScreen( + viewModel = viewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateToMap = { sessionId -> backStack.add(DiscoveryRoute.DiscoveryMap(sessionId)) }, + ) + } + entry { route -> + val viewModel = koinViewModel { parametersOf(route.sessionId) } + DiscoveryMapScreen(viewModel = viewModel, onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }) + } + entry { + val viewModel = koinViewModel() + val navigateToDetail: (Long) -> Unit = { sessionId -> + backStack.add(DiscoveryRoute.DiscoveryHistoryDetail(sessionId)) + } + DiscoveryHistoryScreen( + viewModel = viewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateToDetail = navigateToDetail, + ) + } + entry { route -> + val viewModel = koinViewModel { parametersOf(route.sessionId) } + DiscoveryHistoryDetailScreen( + viewModel = viewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateToMap = { sessionId -> backStack.add(DiscoveryRoute.DiscoveryMap(sessionId)) }, + ) + } +} + +@Composable +private fun DiscoveryScanScreenEntry(backStack: NavBackStack) { + val viewModel = koinViewModel() + DiscoveryScanScreen( + viewModel = viewModel, + onNavigateUp = dropUnlessResumed { backStack.removeLastOrNull() }, + onNavigateToSummary = { sessionId -> backStack.add(DiscoveryRoute.DiscoverySummary(sessionId)) }, + onNavigateToHistory = dropUnlessResumed { backStack.add(DiscoveryRoute.DiscoveryHistory) }, + ) +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt new file mode 100644 index 000000000..f06c35435 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryDetailScreen.kt @@ -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 . + */ +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) + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryScreen.kt new file mode 100644 index 000000000..7728b0d3a --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryHistoryScreen.kt @@ -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 . + */ +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')}" +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryMapScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryMapScreen.kt new file mode 100644 index 000000000..83db549d3 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryMapScreen.kt @@ -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 . + */ +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), + ) + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt new file mode 100644 index 000000000..e26f59b24 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoveryScanScreen.kt @@ -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 . + */ +@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 + } + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt new file mode 100644 index 000000000..3e0bc0c31 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/DiscoverySummaryScreen.kt @@ -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 . + */ +@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, + nodesByPreset: Map>, + algorithmicSummary: String?, + aiSummary: String?, + presetAiSummaries: Map, + 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" +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt new file mode 100644 index 000000000..75384d839 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/DwellProgressIndicator.kt @@ -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 . + */ +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), + ) + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt new file mode 100644 index 000000000..4186be174 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetPickerCard.kt @@ -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 . + */ +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, + 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 + }, + ) + } + } + } + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt new file mode 100644 index 000000000..03eb1fca6 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/PresetResultCard.kt @@ -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 . + */ +@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, + 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, + ) + } +} diff --git a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/RfHealthSection.kt b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/RfHealthSection.kt new file mode 100644 index 000000000..90595f8f0 --- /dev/null +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/ui/component/RfHealthSection.kt @@ -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 . + */ +@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}") + } + } +} diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/.gitkeep b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt new file mode 100644 index 000000000..a3620fd09 --- /dev/null +++ b/feature/discovery/src/commonTest/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngineTest.kt @@ -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 . + */ +@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() + val presetResults = mutableMapOf() + val discoveredNodes = mutableMapOf() + + 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> = + flowOf(sessions.values.sortedByDescending { it.timestamp }) + + override suspend fun getSession(sessionId: Long): DiscoverySessionEntity? = sessions[sessionId] + + override fun getSessionFlow(sessionId: Long): Flow = 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 = + presetResults.values.filter { it.sessionId == sessionId } + + override fun getPresetResultsFlow(sessionId: Long): Flow> = + flowOf(getPresetResultsSynchronous(sessionId)) + + private fun getPresetResultsSynchronous(sessionId: Long): List = + 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) { + nodes.forEach { insertDiscoveredNode(it) } + } + + override suspend fun updateDiscoveredNode(node: DiscoveredNodeEntity) { + discoveredNodes[node.id] = node + } + + override suspend fun getDiscoveredNodes(presetResultId: Long): List = + discoveredNodes.values.filter { it.presetResultId == presetResultId } + + override fun getDiscoveredNodesFlow(presetResultId: Long): Flow> = + flowOf(discoveredNodes.values.filter { it.presetResultId == presetResultId }) + + override suspend fun getUniqueNodeNums(sessionId: Long): List = 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, + ): 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", + ) + } +} diff --git a/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/.gitkeep b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/ai/AlgorithmicSummaryProvider.kt b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/ai/AlgorithmicSummaryProvider.kt new file mode 100644 index 000000000..711cd746f --- /dev/null +++ b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/ai/AlgorithmicSummaryProvider.kt @@ -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 . + */ +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, + ): String = generator.generateSessionSummary(session, presetResults) + + override suspend fun generatePresetSummary(result: DiscoveryPresetResultEntity): String = + generator.generatePresetSummary(result) +} diff --git a/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/TextDiscoveryExporter.kt b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/TextDiscoveryExporter.kt new file mode 100644 index 000000000..d3317fe2d --- /dev/null +++ b/feature/discovery/src/jvmMain/kotlin/org/meshtastic/feature/discovery/export/TextDiscoveryExporter.kt @@ -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 . + */ +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") + } +} diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 99e86e905..db5c6dce2 100644 --- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -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()) diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt index 06ad3df7a..f44c3c389 100644 --- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt +++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/DesktopSettingsScreen.kt @@ -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()) diff --git a/settings.gradle.kts b/settings.gradle.kts index 185a8a65a..db65a310b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -109,6 +109,7 @@ include( ":feature:map", ":feature:node", ":feature:settings", + ":feature:discovery", ":feature:firmware", ":feature:wifi-provision", ":desktop",