mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-12 00:28:20 -04:00
feat: typed telemetry dispatch + MeshTopology service
- Replace magic int dispatch in requestTelemetry with TelemetryType enum - Update DataRequester interface: remove requestId param, use TelemetryType directly - Add HEALTH and TRAFFIC_MANAGEMENT to TelemetryType, remove stale PAX variant - Create MeshTopologyService wrapping SDK's MeshTopology with thread-safe Mutex - Wire NeighborInfo packet ingestion in SdkStateBridge → topology graph - Clear topology on node snapshot (reconnect) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.data.radio
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.koin.core.annotation.Single
|
||||
import org.meshtastic.sdk.MeshTopology
|
||||
import org.meshtastic.sdk.NeighborInfo
|
||||
import org.meshtastic.sdk.NodeId
|
||||
|
||||
/**
|
||||
* Thread-safe wrapper around SDK's [MeshTopology] graph utility.
|
||||
*
|
||||
* Fed by [SdkStateBridge] whenever a NEIGHBORINFO_APP packet arrives. Exposes reactive
|
||||
* topology state for feature modules (map visualization, route analysis, neighbor lists).
|
||||
*
|
||||
* The graph is incrementally built: each [ingestNeighborInfo] call replaces all edges from
|
||||
* the reporting node, keeping the topology fresh as nodes broadcast their neighbor tables.
|
||||
*/
|
||||
@Single
|
||||
class MeshTopologyService {
|
||||
private val topology = MeshTopology()
|
||||
private val mutex = Mutex()
|
||||
|
||||
private val _edges = MutableStateFlow<List<MeshTopology.Edge>>(emptyList())
|
||||
/** All directed edges in the mesh topology graph. */
|
||||
val edges: StateFlow<List<MeshTopology.Edge>> = _edges
|
||||
|
||||
private val _nodeCount = MutableStateFlow(0)
|
||||
/** Total number of nodes participating in the topology (reporters + reported neighbors). */
|
||||
val nodeCount: StateFlow<Int> = _nodeCount
|
||||
|
||||
/**
|
||||
* Ingest a [NeighborInfo] report into the topology graph.
|
||||
* Replaces all prior edges from the reporting node.
|
||||
*/
|
||||
suspend fun ingestNeighborInfo(info: NeighborInfo) {
|
||||
mutex.withLock {
|
||||
topology.addNeighborInfo(info)
|
||||
_edges.value = topology.allEdges()
|
||||
_nodeCount.value = topology.nodes.size
|
||||
}
|
||||
Logger.d { "[Topology] Ingested neighbors from ${info.nodeId}: ${info.neighbors.size} edges" }
|
||||
}
|
||||
|
||||
/** Remove a node from the topology (e.g., when it goes permanently offline). */
|
||||
suspend fun removeNode(nodeId: NodeId) {
|
||||
mutex.withLock {
|
||||
topology.removeNode(nodeId)
|
||||
_edges.value = topology.allEdges()
|
||||
_nodeCount.value = topology.nodes.size
|
||||
}
|
||||
}
|
||||
|
||||
/** Get all neighbors of a specific node (thread-safe snapshot). */
|
||||
suspend fun getNeighbors(nodeId: NodeId): List<MeshTopology.Edge> =
|
||||
mutex.withLock { topology.getNeighbors(nodeId) }
|
||||
|
||||
/** Find the shortest path between two nodes via BFS. */
|
||||
suspend fun shortestPath(from: NodeId, to: NodeId): List<NodeId> =
|
||||
mutex.withLock { topology.shortestPath(from, to) }
|
||||
|
||||
/** Check if two nodes have a direct edge in either direction. */
|
||||
suspend fun isDirectReach(a: NodeId, b: NodeId): Boolean =
|
||||
mutex.withLock { topology.isDirectReach(a, b) }
|
||||
|
||||
/** Clear all topology data (e.g., on disconnect). */
|
||||
suspend fun clear() {
|
||||
mutex.withLock {
|
||||
topology.clear()
|
||||
_edges.value = emptyList()
|
||||
_nodeCount.value = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import org.meshtastic.core.model.MessageSender
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.RemoteAdmin
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.core.repository.MeshLocationManager
|
||||
import org.meshtastic.core.repository.NodeRepository
|
||||
import org.meshtastic.core.repository.RadioPrefs
|
||||
@@ -322,19 +323,18 @@ class SdkRadioController(
|
||||
c.routing.traceRoute(NodeId(destNum))
|
||||
}
|
||||
|
||||
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {
|
||||
override suspend fun requestTelemetry(destNum: Int, type: TelemetryType) {
|
||||
val c = requireClient()
|
||||
val node = NodeId(destNum)
|
||||
when (typeValue) {
|
||||
0 -> c.telemetry.requestDevice(node)
|
||||
1 -> c.telemetry.requestEnvironment(node)
|
||||
2 -> c.telemetry.requestAirQuality(node)
|
||||
3 -> c.telemetry.requestPower(node)
|
||||
4 -> c.telemetry.requestLocalStats()
|
||||
5 -> c.telemetry.requestHealth(node)
|
||||
6 -> c.telemetry.requestHost(node)
|
||||
7 -> c.telemetry.requestTrafficManagement(node)
|
||||
else -> Logger.w { "Unknown telemetry type: $typeValue" }
|
||||
when (type) {
|
||||
TelemetryType.DEVICE -> c.telemetry.requestDevice(node)
|
||||
TelemetryType.ENVIRONMENT -> c.telemetry.requestEnvironment(node)
|
||||
TelemetryType.AIR_QUALITY -> c.telemetry.requestAirQuality(node)
|
||||
TelemetryType.POWER -> c.telemetry.requestPower(node)
|
||||
TelemetryType.LOCAL_STATS -> c.telemetry.requestLocalStats()
|
||||
TelemetryType.HEALTH -> c.telemetry.requestHealth(node)
|
||||
TelemetryType.HOST -> c.telemetry.requestHost(node)
|
||||
TelemetryType.TRAFFIC_MANAGEMENT -> c.telemetry.requestTrafficManagement(node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
@@ -47,6 +48,7 @@ import org.meshtastic.sdk.AdminResult
|
||||
import org.meshtastic.sdk.ChannelIndex
|
||||
import org.meshtastic.sdk.ConnectionState as SdkConnectionState
|
||||
import org.meshtastic.sdk.MeshEvent
|
||||
import org.meshtastic.sdk.NeighborInfo
|
||||
import org.meshtastic.sdk.NodeChange
|
||||
import org.meshtastic.sdk.NodeId
|
||||
import org.meshtastic.sdk.StoreForwardEvent
|
||||
@@ -70,6 +72,7 @@ class SdkStateBridge(
|
||||
private val nodeRepository: NodeRepository,
|
||||
private val packetRepository: Lazy<PacketRepository>,
|
||||
private val locationManager: MeshLocationManager,
|
||||
private val topologyService: MeshTopologyService,
|
||||
private val uiPrefs: UiPrefs,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
@@ -95,6 +98,7 @@ class SdkStateBridge(
|
||||
when (change) {
|
||||
is NodeChange.Snapshot -> {
|
||||
nodeRepository.clear()
|
||||
topologyService.clear()
|
||||
change.nodes.forEach { (_, nodeInfo) ->
|
||||
nodeRepository.installNodeInfo(nodeInfo, withBroadcast = false)
|
||||
}
|
||||
@@ -139,6 +143,25 @@ class SdkStateBridge(
|
||||
.onEach { packet -> serviceRepository.emitMeshPacket(packet) }
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Topology: ingest NeighborInfo packets into MeshTopology graph ────
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.packets ?: flowOf() }
|
||||
.filter { it.decoded?.portnum == PortNum.NEIGHBORINFO_APP }
|
||||
.onEach { packet ->
|
||||
val payload = packet.decoded?.payload?.toByteArray() ?: return@onEach
|
||||
runCatching {
|
||||
val proto = org.meshtastic.proto.NeighborInfo.ADAPTER.decode(payload)
|
||||
val info = NeighborInfo.fromProto(
|
||||
reportingNode = packet.from,
|
||||
neighborNodeIds = proto.neighbors.map { it.node_id },
|
||||
snrValues = proto.neighbors.map { it.snr },
|
||||
timestamp = proto.last_sent_by_id,
|
||||
)
|
||||
topologyService.ingestNeighborInfo(info)
|
||||
}.onFailure { e -> Logger.w(e) { "[SdkBridge] Failed to parse NeighborInfo" } }
|
||||
}
|
||||
.launchIn(scope)
|
||||
|
||||
// ── Events (notifications, security, backpressure) ──────────────────
|
||||
accessor.client
|
||||
.flatMapLatest { client -> client?.events ?: flowOf() }
|
||||
|
||||
@@ -284,6 +284,7 @@ class SdkStateBridgeTest {
|
||||
nodeRepository = nodeRepository,
|
||||
packetRepository = lazyOf(packetRepository),
|
||||
locationManager = NoOpLocationManager,
|
||||
topologyService = MeshTopologyService(),
|
||||
uiPrefs = FakeUiPrefs(),
|
||||
dispatchers = CoroutineDispatchers(
|
||||
io = backgroundScope.coroutineContext[kotlin.coroutines.ContinuationInterceptor] as kotlinx.coroutines.CoroutineDispatcher,
|
||||
|
||||
@@ -21,7 +21,7 @@ interface DataRequester {
|
||||
suspend fun requestPosition(destNum: Int, currentPosition: Position)
|
||||
suspend fun requestUserInfo(destNum: Int)
|
||||
suspend fun requestTraceroute(requestId: Int, destNum: Int)
|
||||
suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int)
|
||||
suspend fun requestTelemetry(destNum: Int, type: TelemetryType)
|
||||
suspend fun requestNeighborInfo(requestId: Int, destNum: Int)
|
||||
suspend fun requestStoreForwardHistory(since: Int? = null, serverNodeNum: Int? = null): Boolean
|
||||
}
|
||||
|
||||
@@ -16,12 +16,19 @@
|
||||
*/
|
||||
package org.meshtastic.core.model
|
||||
|
||||
/**
|
||||
* Typed enum for telemetry request categories.
|
||||
*
|
||||
* Ordinal values align with SDK telemetry dispatch ordering:
|
||||
* 0=Device, 1=Environment, 2=AirQuality, 3=Power, 4=LocalStats, 5=Health, 6=Host, 7=TrafficManagement.
|
||||
*/
|
||||
enum class TelemetryType {
|
||||
DEVICE,
|
||||
ENVIRONMENT,
|
||||
AIR_QUALITY,
|
||||
POWER,
|
||||
LOCAL_STATS,
|
||||
HEALTH,
|
||||
HOST,
|
||||
PAX,
|
||||
TRAFFIC_MANAGEMENT,
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import org.meshtastic.core.model.DataPacket
|
||||
import org.meshtastic.core.model.DeviceAdminEdit
|
||||
import org.meshtastic.core.model.Position
|
||||
import org.meshtastic.core.model.RadioController
|
||||
import org.meshtastic.core.model.TelemetryType
|
||||
import org.meshtastic.proto.Channel
|
||||
import org.meshtastic.proto.ClientNotification
|
||||
import org.meshtastic.proto.Config
|
||||
@@ -141,7 +142,7 @@ class FakeRadioController :
|
||||
|
||||
override suspend fun requestTraceroute(requestId: Int, destNum: Int) {}
|
||||
|
||||
override suspend fun requestTelemetry(requestId: Int, destNum: Int, typeValue: Int) {}
|
||||
override suspend fun requestTelemetry(destNum: Int, type: TelemetryType) {}
|
||||
|
||||
override suspend fun requestNeighborInfo(requestId: Int, destNum: Int) {}
|
||||
|
||||
|
||||
@@ -197,7 +197,7 @@ private fun rememberTelemetricFeatures(
|
||||
TelemetricFeature(
|
||||
titleRes = LogsType.PAX.titleRes,
|
||||
icon = LogsType.PAX.icon,
|
||||
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) },
|
||||
requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) },
|
||||
logsType = LogsType.PAX,
|
||||
),
|
||||
TelemetricFeature(
|
||||
|
||||
@@ -100,8 +100,7 @@ constructor(
|
||||
scope.launch(ioDispatcher) {
|
||||
runCatching {
|
||||
Logger.i { "Requesting telemetry for '$destNum'" }
|
||||
val packetId = messageSender.getPacketId()
|
||||
dataRequester.requestTelemetry(packetId, destNum, type.ordinal)
|
||||
dataRequester.requestTelemetry(destNum, type)
|
||||
|
||||
val typeRes =
|
||||
when (type) {
|
||||
@@ -110,8 +109,9 @@ constructor(
|
||||
TelemetryType.AIR_QUALITY -> Res.string.request_air_quality_metrics
|
||||
TelemetryType.POWER -> Res.string.request_power_metrics
|
||||
TelemetryType.LOCAL_STATS -> Res.string.signal_quality
|
||||
TelemetryType.HEALTH -> Res.string.request_device_metrics
|
||||
TelemetryType.HOST -> Res.string.request_host_metrics
|
||||
TelemetryType.PAX -> Res.string.request_pax_metrics
|
||||
TelemetryType.TRAFFIC_MANAGEMENT -> Res.string.request_device_metrics
|
||||
}
|
||||
|
||||
showFeedback(UiText.Resource(Res.string.requesting_from, typeRes, longName))
|
||||
|
||||
@@ -191,12 +191,12 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel, onNavigateUp: () -> Uni
|
||||
|
||||
BaseMetricScreen(
|
||||
onNavigateUp = onNavigateUp,
|
||||
telemetryType = TelemetryType.PAX,
|
||||
telemetryType = TelemetryType.DEVICE,
|
||||
titleRes = Res.string.pax_metrics_log,
|
||||
nodeName = state.node?.user?.long_name ?: "",
|
||||
data = paxMetrics,
|
||||
timeProvider = { (it.first.received_date / MS_PER_SEC).toDouble() },
|
||||
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.PAX) },
|
||||
onRequestTelemetry = { metricsViewModel.requestTelemetry(TelemetryType.DEVICE) },
|
||||
controlPart = {
|
||||
TimeFrameSelector(
|
||||
selectedTimeFrame = timeFrame,
|
||||
|
||||
@@ -41,7 +41,7 @@ class RefreshLocalStatsAction :
|
||||
return
|
||||
}
|
||||
|
||||
radioController.requestTelemetry(myNodeNum.hashCode(), myNodeNum, TelemetryType.LOCAL_STATS.ordinal)
|
||||
radioController.requestTelemetry(myNodeNum.hashCode() + 1, myNodeNum, TelemetryType.DEVICE.ordinal)
|
||||
radioController.requestTelemetry(myNodeNum, TelemetryType.LOCAL_STATS)
|
||||
radioController.requestTelemetry(myNodeNum, TelemetryType.DEVICE)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user