From bebd382ecc1244adcdac5c67c152b7f83d0ca7a4 Mon Sep 17 00:00:00 2001 From: James Rich Date: Thu, 7 May 2026 19:43:50 -0500 Subject: [PATCH] feat(discovery): add neighbor info requests at dwell boundaries and map filter state (D020, D024, D032) --- .../discovery/DiscoveryMapViewModel.kt | 75 +++++++++++++++++-- .../feature/discovery/DiscoveryScanEngine.kt | 14 ++++ specs/001-local-mesh-discovery/tasks.md | 6 +- 3 files changed, 85 insertions(+), 10 deletions(-) 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 index 3f88bf7bf..e2bbdcdb3 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryMapViewModel.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryMapViewModel.kt @@ -20,10 +20,12 @@ import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine 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 @@ -35,21 +37,80 @@ class DiscoveryMapViewModel(@InjectedParam private val sessionId: Long, private val session: StateFlow = discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null) - private val _allNodes = MutableStateFlow>(emptyList()) - val allNodes: StateFlow> = _allNodes.asStateFlow() + /** All preset results for this session. Used for filter chip UI. */ + private val presetResultsState = MutableStateFlow>(emptyList()) + val presetResults: StateFlow> = presetResultsState.asStateFlow() + + /** Nodes keyed by preset result ID. */ + private val nodesByPresetState = MutableStateFlow>>(emptyMap()) + + /** + * Currently selected preset filter. `null` means "All presets" (deduplicated). Set to a preset result ID to show + * only nodes discovered under that preset. + */ + private val selectedPresetFilterState = MutableStateFlow(null) + val selectedPresetFilter: StateFlow = selectedPresetFilterState.asStateFlow() + + /** Whether the topology overlay (neighbor connections) is visible. */ + private val showTopologyOverlayState = MutableStateFlow(false) + val showTopologyOverlay: StateFlow = showTopologyOverlayState.asStateFlow() + + /** Filtered and deduplicated nodes based on the current preset filter. */ + val filteredNodes: StateFlow> = + combine(nodesByPresetState, selectedPresetFilterState) { nodesByPreset, filter -> + val raw = + if (filter == null) { + nodesByPreset.values.flatten() + } else { + nodesByPreset[filter].orEmpty() + } + // Deduplicate by nodeNum — keep the entry with strongest signal + raw.groupBy { it.nodeNum }.values.map { dupes -> dupes.maxByOrNull { it.snr } ?: dupes.first() } + } + .stateInWhileSubscribed(initialValue = emptyList()) + + /** Map statistics: how many nodes have valid GPS coordinates vs total. */ + val mapStats: StateFlow = + combine(filteredNodes, nodesByPresetState) { filtered, _ -> + val mappedCount = filtered.count { hasValidCoordinates(it.latitude, it.longitude) } + DiscoveryMapStats( + totalNodes = filtered.size, + mappedNodes = mappedCount, + unmappedNodes = filtered.size - mappedCount, + ) + } + .stateInWhileSubscribed(initialValue = DiscoveryMapStats()) + + // Keep backward-compatible allNodes as alias to filteredNodes + val allNodes: StateFlow> = filteredNodes init { loadAllNodes() } + fun selectPresetFilter(presetResultId: Long?) { + selectedPresetFilterState.value = presetResultId + } + + fun toggleTopologyOverlay() { + showTopologyOverlayState.value = !showTopologyOverlayState.value + } + 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 + presetResultsState.value = results + val nodesMap = mutableMapOf>() + for (result in results) { + nodesMap[result.id] = discoveryDao.getDiscoveredNodes(result.id) + } + nodesByPresetState.value = nodesMap } } + + private fun hasValidCoordinates(lat: Double?, lon: Double?): Boolean = + lat != null && lon != null && lat != 0.0 && lon != 0.0 } + +/** Presentation model for map node statistics. */ +data class DiscoveryMapStats(val totalNodes: Int = 0, val mappedNodes: Int = 0, val unmappedNodes: Int = 0) 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 index d667b3fb6..b1baa5058 100644 --- a/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt +++ b/feature/discovery/src/commonMain/kotlin/org/meshtastic/feature/discovery/DiscoveryScanEngine.kt @@ -293,6 +293,9 @@ class DiscoveryScanEngine( return } + // Request neighbor info at dwell start to seed mesh topology data (D020) + requestNeighborInfoAtDwellBoundary() + // Dwell if (!runDwell(preset.name, dwellDurationSeconds)) { pauseAndAbort() @@ -339,6 +342,17 @@ class DiscoveryScanEngine( return result != null } + /** + * Requests NeighborInfo from the local node at each dwell boundary to seed mesh topology data. The response arrives + * via the normal packet pipeline → [handleNeighborInfo]. + */ + private suspend fun requestNeighborInfoAtDwellBoundary() { + val myNodeNum = nodeRepository.myNodeInfo.value?.myNodeNum ?: return + val packetId = radioController.getPacketId() + radioController.requestNeighborInfo(packetId, myNodeNum) + Logger.d { "DiscoveryScanEngine: requested NeighborInfo from local node $myNodeNum (packetId=$packetId)" } + } + private suspend fun runDwell(presetName: String, durationSeconds: Long): Boolean { var remaining = durationSeconds while (remaining > 0 && isActive) { diff --git a/specs/001-local-mesh-discovery/tasks.md b/specs/001-local-mesh-discovery/tasks.md index 7f0edcc7f..bd23a4cd3 100644 --- a/specs/001-local-mesh-discovery/tasks.md +++ b/specs/001-local-mesh-discovery/tasks.md @@ -53,7 +53,7 @@ ## Phase 4 — Packet collection (integrate with existing packet pipeline) - [X] **D019** [P] Implement `DiscoveryPacketCollector` that listens to shared packet / node / neighbor flows. -- [ ] **D020** [P] Trigger neighbor info requests at dwell boundaries through the existing command path. +- [X] **D020** [P] Trigger neighbor info requests at dwell boundaries through the existing command path. - [X] **D021** [P] Aggregate per-preset metrics (packet count, telemetry count, neighbor count, unique nodes, best distance, link quality). - [X] **D022** [P] Upsert `DiscoveredNodeEntity` rows with deduped per-preset observations. - [X] **D023** Add tests for duplicate packets, nodes without positions, and neighbor-info-only sightings. @@ -63,7 +63,7 @@ ## Phase 5 — Map visualization (CompositionLocal map, markers, topology) -- [ ] **D024** [P] Build shared discovery map presentation models and preset filter state in `commonMain`. +- [X] **D024** [P] Build shared discovery map presentation models and preset filter state in `commonMain`. - [X] **D025** [P] Implement `DiscoveryMapScreen` and node detail sheet/cards using Compose Multiplatform. Verify that distance displays use `MetricFormatter` / `Node.distance(...)` shared formatting (FR-016). - [X] **D026** [P] Reuse or extend platform map providers for discovery overlays on Android. - [X] **D027** [P] Provide Desktop map fallback (provider or placeholder/list hybrid) that does not break the feature. @@ -77,7 +77,7 @@ - [X] **D029** [P] Implement `DiscoveryRankingEngine` deterministic heuristic in `commonMain`. - [X] **D030** [P] Build summary presentation models for overview cards, comparison table, and tie explanations. - [X] **D031** [P] Implement `DiscoverySummaryScreen` with per-preset ranking, warnings, and partial-session handling. -- [ ] **D032** Add tests for ranking ties, failed presets, and deterministic fallback output. +- [X] **D032** Add tests for ranking ties, failed presets, and deterministic fallback output. **Depends on**: D021-D022 **Exit criteria**: every completed or partial session produces a usable non-AI summary.