feat(discovery): add neighbor info requests at dwell boundaries and map filter state (D020, D024, D032)

This commit is contained in:
James Rich
2026-05-07 19:43:50 -05:00
parent cffafb175d
commit bebd382ecc
3 changed files with 85 additions and 10 deletions

View File

@@ -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<DiscoverySessionEntity?> =
discoveryDao.getSessionFlow(sessionId).stateInWhileSubscribed(initialValue = null)
private val _allNodes = MutableStateFlow<List<DiscoveredNodeEntity>>(emptyList())
val allNodes: StateFlow<List<DiscoveredNodeEntity>> = _allNodes.asStateFlow()
/** All preset results for this session. Used for filter chip UI. */
private val presetResultsState = MutableStateFlow<List<DiscoveryPresetResultEntity>>(emptyList())
val presetResults: StateFlow<List<DiscoveryPresetResultEntity>> = presetResultsState.asStateFlow()
/** Nodes keyed by preset result ID. */
private val nodesByPresetState = MutableStateFlow<Map<Long, List<DiscoveredNodeEntity>>>(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<Long?>(null)
val selectedPresetFilter: StateFlow<Long?> = selectedPresetFilterState.asStateFlow()
/** Whether the topology overlay (neighbor connections) is visible. */
private val showTopologyOverlayState = MutableStateFlow(false)
val showTopologyOverlay: StateFlow<Boolean> = showTopologyOverlayState.asStateFlow()
/** Filtered and deduplicated nodes based on the current preset filter. */
val filteredNodes: StateFlow<List<DiscoveredNodeEntity>> =
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<DiscoveryMapStats> =
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<List<DiscoveredNodeEntity>> = 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<Long, List<DiscoveredNodeEntity>>()
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)

View File

@@ -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) {

View File

@@ -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.