Skip to content

Commit 36fdf04

Browse files
committed
Add Local Mesh Discovery feature
* Introduce a new `:feature:discovery` module for scanning mesh topology across multiple LoRa presets * Add `DiscoveryScanEngine` to manage scan lifecycles, preset shifting, and packet collection * Update database schema to version 39 with tables for discovery sessions, preset results, and discovered nodes * Implement UI screens for scan configuration, real-time progress, and historical session management * Add flavor-specific discovery maps (Google Maps and OSM) for visualizing node positions and topology * Include algorithmic and AI-powered summary generation for analyzing LoRa preset performance * Add report export functionality for Text and PDF formats * Integrate discovery entry point into the settings screen and navigation graphs
1 parent 09fd7f5 commit 36fdf04

67 files changed

Lines changed: 6406 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ dependencies {
233233
implementation(projects.feature.map)
234234
implementation(projects.feature.node)
235235
implementation(projects.feature.settings)
236+
implementation(projects.feature.discovery)
236237
implementation(projects.feature.firmware)
237238
implementation(projects.feature.wifiProvision)
238239
implementation(projects.feature.widget)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.app.map.discovery
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.Modifier
21+
import org.meshtastic.core.ui.util.DiscoveryMapNode
22+
23+
/** Flavor-unified entry point for the discovery map. OSMDroid implementation. */
24+
@Composable
25+
fun DiscoveryMap(
26+
userLatitude: Double,
27+
userLongitude: Double,
28+
nodes: List<DiscoveryMapNode>,
29+
modifier: Modifier = Modifier,
30+
) {
31+
DiscoveryOsmMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
32+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("MagicNumber")
18+
19+
package org.meshtastic.app.map.discovery
20+
21+
import android.graphics.Paint
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.LaunchedEffect
24+
import androidx.compose.runtime.getValue
25+
import androidx.compose.runtime.mutableStateOf
26+
import androidx.compose.runtime.remember
27+
import androidx.compose.runtime.setValue
28+
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.graphics.toArgb
30+
import androidx.compose.ui.platform.LocalContext
31+
import androidx.compose.ui.platform.LocalDensity
32+
import androidx.compose.ui.unit.dp
33+
import androidx.compose.ui.viewinterop.AndroidView
34+
import org.meshtastic.app.map.addCopyright
35+
import org.meshtastic.app.map.addScaleBarOverlay
36+
import org.meshtastic.app.map.model.CustomTileSource
37+
import org.meshtastic.app.map.rememberMapViewWithLifecycle
38+
import org.meshtastic.app.map.zoomIn
39+
import org.meshtastic.core.ui.theme.DiscoveryMapColors
40+
import org.meshtastic.core.ui.util.DiscoveryMapNode
41+
import org.meshtastic.core.ui.util.DiscoveryNeighborType
42+
import org.osmdroid.util.BoundingBox
43+
import org.osmdroid.util.GeoPoint
44+
import org.osmdroid.views.overlay.Marker
45+
import org.osmdroid.views.overlay.Polyline
46+
47+
private const val SINGLE_POINT_ZOOM = 14.0
48+
private const val ZOOM_OUT_LEVELS = 0.5
49+
50+
/**
51+
* OSMDroid implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green =
52+
* direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
53+
*/
54+
@Composable
55+
fun DiscoveryOsmMap(
56+
userLatitude: Double,
57+
userLongitude: Double,
58+
nodes: List<DiscoveryMapNode>,
59+
modifier: Modifier = Modifier,
60+
) {
61+
val context = LocalContext.current
62+
val density = LocalDensity.current
63+
val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
64+
val userGeoPoint = remember(userLatitude, userLongitude) { GeoPoint(userLatitude, userLongitude) }
65+
val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
66+
67+
// Build bounding box from all points
68+
val allGeoPoints =
69+
remember(validNodes, hasValidUserPosition) {
70+
buildList {
71+
if (hasValidUserPosition) add(userGeoPoint)
72+
validNodes.forEach { add(GeoPoint(it.latitude, it.longitude)) }
73+
}
74+
}
75+
val initialBounds =
76+
remember(allGeoPoints) {
77+
if (allGeoPoints.isEmpty()) BoundingBox() else BoundingBox.fromGeoPoints(allGeoPoints)
78+
}
79+
80+
var hasCentered by remember { mutableStateOf(false) }
81+
82+
val mapView =
83+
rememberMapViewWithLifecycle(
84+
applicationId = context.packageName,
85+
box = initialBounds,
86+
tileSource = CustomTileSource.getTileSource(0),
87+
)
88+
89+
// Camera auto-center once
90+
LaunchedEffect(allGeoPoints) {
91+
if (hasCentered || allGeoPoints.isEmpty()) return@LaunchedEffect
92+
if (allGeoPoints.size == 1) {
93+
mapView.controller.setCenter(allGeoPoints.first())
94+
mapView.controller.setZoom(SINGLE_POINT_ZOOM)
95+
} else {
96+
mapView.zoomToBoundingBox(BoundingBox.fromGeoPoints(allGeoPoints).zoomIn(-ZOOM_OUT_LEVELS), true)
97+
}
98+
hasCentered = true
99+
}
100+
101+
AndroidView(
102+
modifier = modifier,
103+
factory = { mapView.apply { setDestroyMode(false) } },
104+
update = { map ->
105+
map.overlays.clear()
106+
map.addCopyright()
107+
map.addScaleBarOverlay(density)
108+
109+
// User position marker
110+
if (hasValidUserPosition) {
111+
val userMarker =
112+
Marker(map).apply {
113+
position = userGeoPoint
114+
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
115+
title = "Your Position"
116+
icon = context.getDrawable(android.R.drawable.ic_menu_mylocation)
117+
}
118+
map.overlays.add(userMarker)
119+
}
120+
121+
// Node markers
122+
validNodes.forEach { node ->
123+
val nodeGeoPoint = GeoPoint(node.latitude, node.longitude)
124+
val marker =
125+
Marker(map).apply {
126+
position = nodeGeoPoint
127+
setAnchor(Marker.ANCHOR_CENTER, Marker.ANCHOR_BOTTOM)
128+
title = node.longName ?: node.shortName ?: "Unknown"
129+
snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm"
130+
131+
val drawableId =
132+
if (node.isSensorNode) {
133+
org.meshtastic.app.R.drawable.ic_thermostat
134+
} else {
135+
org.meshtastic.app.R.drawable.ic_person
136+
}
137+
icon = context.getDrawable(drawableId)
138+
139+
// Default OSM marker handles color tinting via icon overlay or custom drawables if needed,
140+
// but setting the icon directly overrides the default teardrop pin.
141+
}
142+
map.overlays.add(marker)
143+
}
144+
145+
// Polylines from user to direct neighbors
146+
if (hasValidUserPosition) {
147+
validNodes
148+
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
149+
.forEach { node ->
150+
val polyline =
151+
Polyline().apply {
152+
setPoints(listOf(userGeoPoint, GeoPoint(node.latitude, node.longitude)))
153+
outlinePaint.apply {
154+
color = DiscoveryMapColors.DirectLine.toArgb()
155+
strokeWidth = with(density) { 3.dp.toPx() }
156+
strokeCap = Paint.Cap.ROUND
157+
style = Paint.Style.STROKE
158+
}
159+
}
160+
map.overlays.add(polyline)
161+
}
162+
}
163+
164+
map.invalidate()
165+
},
166+
)
167+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("MagicNumber")
18+
19+
package org.meshtastic.app.map.discovery
20+
21+
import androidx.compose.foundation.isSystemInDarkTheme
22+
import androidx.compose.runtime.Composable
23+
import androidx.compose.runtime.LaunchedEffect
24+
import androidx.compose.runtime.remember
25+
import androidx.compose.ui.Modifier
26+
import androidx.compose.ui.graphics.Color
27+
import com.google.android.gms.maps.CameraUpdateFactory
28+
import com.google.android.gms.maps.model.CameraPosition
29+
import com.google.android.gms.maps.model.LatLng
30+
import com.google.android.gms.maps.model.LatLngBounds
31+
import com.google.maps.android.compose.ComposeMapColorScheme
32+
import com.google.maps.android.compose.GoogleMap
33+
import com.google.maps.android.compose.MapUiSettings
34+
import com.google.maps.android.compose.MapsComposeExperimentalApi
35+
import com.google.maps.android.compose.MarkerComposable
36+
import com.google.maps.android.compose.Polyline
37+
import com.google.maps.android.compose.rememberCameraPositionState
38+
import com.google.maps.android.compose.rememberUpdatedMarkerState
39+
import org.meshtastic.core.ui.util.DiscoveryMapNode
40+
import org.meshtastic.core.ui.util.DiscoveryNeighborType
41+
42+
private const val DEFAULT_ZOOM = 12f
43+
private const val BOUNDS_PADDING_PX = 100
44+
45+
private val DirectColor = Color(0xFF4CAF50)
46+
private val MeshColor = Color(0xFF2196F3)
47+
private val UserColor = Color(0xFFFF9800)
48+
private val DirectLineColor = Color(0xFF4CAF50).copy(alpha = 0.5f)
49+
50+
/**
51+
* Google Maps implementation of the discovery map. Renders discovered node markers color-coded by neighbor type (green
52+
* = direct, blue = mesh) with polylines from the user position to direct neighbors. Auto-zooms to fit all markers.
53+
*/
54+
@OptIn(MapsComposeExperimentalApi::class)
55+
@Composable
56+
fun DiscoveryGoogleMap(
57+
userLatitude: Double,
58+
userLongitude: Double,
59+
nodes: List<DiscoveryMapNode>,
60+
modifier: Modifier = Modifier,
61+
) {
62+
val dark = isSystemInDarkTheme()
63+
val mapColorScheme = if (dark) ComposeMapColorScheme.DARK else ComposeMapColorScheme.LIGHT
64+
65+
val userLatLng = remember(userLatitude, userLongitude) { LatLng(userLatitude, userLongitude) }
66+
val hasValidUserPosition = userLatitude != 0.0 || userLongitude != 0.0
67+
val validNodes = remember(nodes) { nodes.filter { it.latitude != 0.0 || it.longitude != 0.0 } }
68+
69+
val cameraState = rememberCameraPositionState {
70+
position =
71+
CameraPosition.fromLatLngZoom(if (hasValidUserPosition) userLatLng else LatLng(0.0, 0.0), DEFAULT_ZOOM)
72+
}
73+
74+
// Auto-fit bounds on first composition
75+
LaunchedEffect(validNodes, hasValidUserPosition) {
76+
val allPoints = buildList {
77+
if (hasValidUserPosition) add(userLatLng)
78+
validNodes.forEach { add(LatLng(it.latitude, it.longitude)) }
79+
}
80+
if (allPoints.size >= 2) {
81+
val boundsBuilder = LatLngBounds.builder()
82+
allPoints.forEach { boundsBuilder.include(it) }
83+
cameraState.animate(CameraUpdateFactory.newLatLngBounds(boundsBuilder.build(), BOUNDS_PADDING_PX))
84+
} else if (allPoints.size == 1) {
85+
cameraState.animate(CameraUpdateFactory.newLatLngZoom(allPoints.first(), DEFAULT_ZOOM))
86+
}
87+
}
88+
89+
GoogleMap(
90+
mapColorScheme = mapColorScheme,
91+
modifier = modifier,
92+
uiSettings =
93+
MapUiSettings(
94+
zoomControlsEnabled = true,
95+
mapToolbarEnabled = false,
96+
compassEnabled = true,
97+
myLocationButtonEnabled = false,
98+
),
99+
cameraPositionState = cameraState,
100+
) {
101+
// User position marker
102+
if (hasValidUserPosition) {
103+
MarkerComposable(state = rememberUpdatedMarkerState(position = userLatLng), title = "Your Position") {
104+
DiscoveryMarkerChip(label = "You", color = UserColor)
105+
}
106+
}
107+
108+
// Node markers
109+
validNodes.forEach { node ->
110+
val nodeLatLng = LatLng(node.latitude, node.longitude)
111+
val markerColor =
112+
when (node.neighborType) {
113+
DiscoveryNeighborType.DIRECT -> DirectColor
114+
DiscoveryNeighborType.MESH -> MeshColor
115+
}
116+
val nodeIcon =
117+
if (node.isSensorNode) {
118+
org.meshtastic.core.ui.icon.MeshtasticIcons.Temperature
119+
} else {
120+
org.meshtastic.core.ui.icon.MeshtasticIcons.Person
121+
}
122+
MarkerComposable(
123+
state = rememberUpdatedMarkerState(position = nodeLatLng),
124+
title = node.longName ?: node.shortName ?: "Unknown",
125+
snippet = "SNR: ${node.snr} dB / RSSI: ${node.rssi} dBm",
126+
) {
127+
DiscoveryMarkerChip(label = node.shortName ?: "?", color = markerColor, icon = nodeIcon)
128+
}
129+
}
130+
131+
// Polylines from user to direct neighbors
132+
if (hasValidUserPosition) {
133+
validNodes
134+
.filter { it.neighborType == DiscoveryNeighborType.DIRECT }
135+
.forEach { node ->
136+
Polyline(
137+
points = listOf(userLatLng, LatLng(node.latitude, node.longitude)),
138+
color = DirectLineColor,
139+
width = 4f,
140+
)
141+
}
142+
}
143+
}
144+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
package org.meshtastic.app.map.discovery
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.Modifier
21+
import org.meshtastic.core.ui.util.DiscoveryMapNode
22+
23+
/** Flavor-unified entry point for the discovery map. Google Maps implementation. */
24+
@Composable
25+
fun DiscoveryMap(
26+
userLatitude: Double,
27+
userLongitude: Double,
28+
nodes: List<DiscoveryMapNode>,
29+
modifier: Modifier = Modifier,
30+
) {
31+
DiscoveryGoogleMap(userLatitude = userLatitude, userLongitude = userLongitude, nodes = nodes, modifier = modifier)
32+
}

0 commit comments

Comments
 (0)