mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 16:55:02 -04:00
feat(discovery): add neighbor info requests at dwell boundaries and map filter state (D020, D024, D032)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user