feat: Accurately display outgoing diagnostic packets (#4569)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich
2026-02-16 16:09:21 -06:00
committed by GitHub
parent 6a244316b2
commit c690ddc7ea
18 changed files with 922 additions and 381 deletions

View File

@@ -85,9 +85,13 @@ fun PositionSection(
Spacer(Modifier.height(8.dp))
}
if (!isLocal) {
PositionActionButtons(node, hasValidPosition, metricsState.displayUnits, onAction)
}
PositionActionButtons(
node = node,
isLocal = isLocal,
hasValidPosition = hasValidPosition,
displayUnits = metricsState.displayUnits,
onAction = onAction,
)
if (availableLogs.contains(LogsType.NODE_MAP) || availableLogs.contains(LogsType.POSITIONS)) {
Spacer(Modifier.height(12.dp))
@@ -147,39 +151,44 @@ private fun PositionMap(node: Node, distance: String?) {
@Composable
private fun PositionActionButtons(
node: Node,
isLocal: Boolean,
hasValidPosition: Boolean,
displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
) {
if (isLocal && !hasValidPosition) return
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(Icons.Rounded.LocationOn, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.exchange_position),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Visible,
)
if (!isLocal) {
Button(
onClick = { onAction(NodeDetailAction.HandleNodeMenuAction(NodeMenuAction.RequestPosition(node))) },
modifier = Modifier.weight(EXCHANGE_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
) {
Icon(Icons.Rounded.LocationOn, null, Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(Res.string.exchange_position),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Visible,
)
}
}
if (hasValidPosition) {
FilledTonalButton(
onClick = { onAction(NodeDetailAction.OpenCompass(node, displayUnits)) },
modifier = Modifier.weight(COMPASS_BUTTON_WEIGHT),
modifier = if (isLocal) Modifier.fillMaxWidth() else Modifier.weight(COMPASS_BUTTON_WEIGHT),
shape = MaterialTheme.shapes.large,
) {
Icon(Icons.Rounded.Explore, null, Modifier.size(18.dp))

View File

@@ -23,6 +23,7 @@ import androidx.navigation.toRoute
import com.meshtastic.core.strings.getString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -30,7 +31,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -42,11 +42,11 @@ import org.meshtastic.core.data.repository.NodeRepository
import org.meshtastic.core.data.repository.RadioConfigRepository
import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.entity.MyNodeEntity
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.util.isLora
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.isDirectSignal
import org.meshtastic.core.navigation.NodesRoutes
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.core.service.ServiceRepository
@@ -58,14 +58,24 @@ import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import javax.inject.Inject
/**
* UI state for the Node Details screen.
*
* @property node The node being viewed, or null if loading.
* @property ourNode Information about the locally connected node.
* @property metricsState Aggregated sensor and signal metrics.
* @property environmentState Standardized environmental sensor data.
* @property availableLogs A set of log types available for this node.
* @property lastTracerouteTime Timestamp of the last successful traceroute request.
* @property lastRequestNeighborsTime Timestamp of the last successful neighbor info request.
*/
data class NodeDetailUiState(
val node: Node? = null,
val ourNode: Node? = null,
@@ -76,6 +86,9 @@ data class NodeDetailUiState(
val lastRequestNeighborsTime: Long? = null,
)
/**
* ViewModel for the Node Details screen, coordinating data from the node database, mesh logs, and radio configuration.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class NodeDetailViewModel
@@ -102,182 +115,171 @@ constructor(
combine(MutableStateFlow(nodeIdFromRoute), manualNodeId) { fromRoute, manual -> manual ?: fromRoute }
.distinctUntilChanged()
private val ourNodeNumFlow = nodeRepository.nodeDBbyNum.map { it.keys.firstOrNull() }.distinctUntilChanged()
/** Primary UI state stream, combining identity, metrics, and global device metadata. */
val uiState: StateFlow<NodeDetailUiState> =
activeNodeId
.flatMapLatest { nodeId ->
if (nodeId == null) return@flatMapLatest flowOf(NodeDetailUiState())
val nodeFlow = nodeRepository.nodeDBbyNum.map { it[nodeId] }.distinctUntilChanged()
val telemetryFlow = meshLogRepository.getTelemetryFrom(nodeId).distinctUntilChanged()
val packetsFlow = meshLogRepository.getMeshPacketsFrom(nodeId).distinctUntilChanged()
val posPacketsFlow =
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value).distinctUntilChanged()
val paxLogsFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value).distinctUntilChanged()
val trReqsFlow =
meshLogRepository
.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value)
.map { logs ->
logs.filter { log ->
val pkt = log.fromRadio.packet
val decoded = pkt?.decoded
pkt != null &&
decoded != null &&
decoded.want_response == true &&
pkt.from == 0 &&
pkt.to == nodeId
}
}
.distinctUntilChanged()
val trResFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value).distinctUntilChanged()
val niReqsFlow =
meshLogRepository
.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value)
.map { logs ->
logs.filter { log ->
val pkt = log.fromRadio.packet
val decoded = pkt?.decoded
pkt != null &&
decoded != null &&
decoded.want_response == true &&
pkt.from == 0 &&
pkt.to == nodeId
}
}
.distinctUntilChanged()
val niResFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value).distinctUntilChanged()
combine(
nodeRepository.ourNodeInfo,
ourNodeNumFlow,
nodeFlow,
nodeRepository.myNodeInfo,
radioConfigRepository.deviceProfileFlow,
telemetryFlow,
packetsFlow,
posPacketsFlow,
paxLogsFlow,
trReqsFlow,
trResFlow,
niReqsFlow,
niResFlow,
meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes,
nodeRequestActions.lastRequestNeighborTimes,
) { args ->
@Suppress("UNCHECKED_CAST")
NodeDetailUiStateData(
nodeId = nodeId,
actualNode = (args[2] as Node?) ?: createFallbackNode(nodeId),
ourNode = args[0] as Node?,
ourNodeNum = args[1] as Int?,
myInfo = (args[3] as MyNodeEntity?)?.toMyNodeInfo(),
profile = args[4] as org.meshtastic.proto.DeviceProfile,
telemetry = args[5] as List<Telemetry>,
packets = args[6] as List<MeshPacket>,
positionPackets = args[7] as List<MeshPacket>,
paxLogs = args[8] as List<MeshLog>,
tracerouteRequests = args[9] as List<MeshLog>,
tracerouteResults = args[10] as List<MeshLog>,
neighborInfoRequests = args[11] as List<MeshLog>,
neighborInfoResults = args[12] as List<MeshLog>,
firmwareEditionArg = args[13] as? FirmwareEdition,
stable = args[14] as FirmwareRelease?,
alpha = args[15] as FirmwareRelease?,
lastTracerouteTime = (args[16] as Map<Int, Long>)[nodeId],
lastRequestNeighborsTime = (args[17] as Map<Int, Long>)[nodeId],
)
}
.flatMapLatest { data ->
val pioEnv = if (data.nodeId == data.ourNodeNum) data.myInfo?.pioEnv else null
val hwModel = data.actualNode.user.hw_model?.value ?: 0
flow {
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, pioEnv).getOrNull()
val moduleConfig = data.profile.module_config
val displayUnits = data.profile.config?.display?.units
val metricsState =
MetricsState(
node = data.actualNode,
isLocal = data.nodeId == data.ourNodeNum,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = data.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC,
deviceMetrics = data.telemetry.filter { it.device_metrics != null },
powerMetrics = data.telemetry.filter { it.power_metrics != null },
hostMetrics = data.telemetry.filter { it.host_metrics != null },
signalMetrics =
data.packets.filter { pkt ->
(pkt.rx_time ?: 0) > 0 &&
pkt.hop_start == pkt.hop_limit &&
pkt.via_mqtt != true &&
pkt.isLora()
},
positionLogs = data.positionPackets.mapNotNull { it.toPosition() },
paxMetrics = data.paxLogs,
tracerouteRequests = data.tracerouteRequests,
tracerouteResults = data.tracerouteResults,
neighborInfoRequests = data.neighborInfoRequests,
neighborInfoResults = data.neighborInfoResults,
firmwareEdition = data.firmwareEditionArg,
latestStableFirmware = data.stable ?: FirmwareRelease(),
latestAlphaFirmware = data.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(
environmentMetrics =
data.telemetry.filter {
val em = it.environment_metrics
em != null &&
em.relative_humidity != null &&
em.temperature != null &&
em.temperature!!.isNaN().not()
},
)
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
emit(
NodeDetailUiState(
node = metricsState.node,
ourNode = data.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = data.lastTracerouteTime,
lastRequestNeighborsTime = data.lastRequestNeighborsTime,
),
)
}
}
buildUiStateFlow(nodeId)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NodeDetailUiState())
@Suppress("LongMethod", "CyclomaticComplexMethod")
private fun buildUiStateFlow(nodeId: Int): Flow<NodeDetailUiState> {
val nodeFlow =
nodeRepository.nodeDBbyNum
.map { it[nodeId] ?: Node.createFallback(nodeId, getString(Res.string.fallback_node_name)) }
.distinctUntilChanged()
// 1. Logs & Metrics Data (fetches telemetry, packets, paxcount, and response history)
val metricsLogsFlow =
combine(
meshLogRepository.getTelemetryFrom(nodeId),
meshLogRepository.getMeshPacketsFrom(nodeId),
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value),
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value),
) { args: Array<List<Any?>> ->
@Suppress("UNCHECKED_CAST")
LogsGroup(
telemetry = args[0] as List<Telemetry>,
packets = args[1] as List<MeshPacket>,
posPackets = args[2] as List<MeshPacket>,
pax = args[3] as List<MeshLog>,
trRes = args[4] as List<MeshLog>,
niRes = args[5] as List<MeshLog>,
)
}
// 2. Identity & Config (local device info and radio profile)
val identityFlow =
combine(nodeRepository.ourNodeInfo, nodeRepository.myNodeInfo, radioConfigRepository.deviceProfileFlow) {
ourNode,
myInfo,
profile,
->
IdentityGroup(ourNode, myInfo?.toMyNodeInfo(), profile)
}
// 3. Metadata & Request Timestamps (firmware versions and last request times)
val metadataFlow =
combine(
meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes.map { it[nodeId] },
nodeRequestActions.lastRequestNeighborTimes.map { it[nodeId] },
) { args: Array<Any?> ->
MetadataGroup(
edition = args[0] as? FirmwareEdition,
stable = args[1] as? FirmwareRelease,
alpha = args[2] as? FirmwareRelease,
trTime = args[3] as? Long,
niTime = args[4] as? Long,
)
}
// 4. Requests History (tracking traceroute and neighbor info requests sent from this device)
val requestsFlow =
combine(
meshLogRepository.getRequestLogs(nodeId, PortNum.TRACEROUTE_APP),
meshLogRepository.getRequestLogs(nodeId, PortNum.NEIGHBORINFO_APP),
) { trReqs, niReqs ->
trReqs to niReqs
}
// Assemble final UI state
return combine(nodeFlow, metricsLogsFlow, identityFlow, metadataFlow, requestsFlow) {
node,
logs,
identity,
metadata,
requests,
->
val (trReqs, niReqs) = requests
val isLocal = node.num == identity.ourNode?.num
val pioEnv = if (isLocal) identity.myInfo?.pioEnv else null
val hw = deviceHardwareRepository.getDeviceHardwareByModel(node.user.hw_model.value, pioEnv).getOrNull()
val moduleConfig = identity.profile.module_config
val displayUnits = identity.profile.config?.display?.units ?: Config.DisplayConfig.DisplayUnits.METRIC
val metricsState =
MetricsState(
node = node,
isLocal = isLocal,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = identity.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = logs.telemetry.filter { it.device_metrics != null },
powerMetrics = logs.telemetry.filter { it.power_metrics != null },
hostMetrics = logs.telemetry.filter { it.host_metrics != null },
signalMetrics = logs.packets.filter { it.isDirectSignal() },
positionLogs = logs.posPackets.mapNotNull { it.toPosition() },
paxMetrics = logs.pax,
tracerouteRequests = trReqs,
tracerouteResults = logs.trRes,
neighborInfoRequests = niReqs,
neighborInfoResults = logs.niRes,
firmwareEdition = metadata.edition,
latestStableFirmware = metadata.stable ?: FirmwareRelease(),
latestAlphaFirmware = metadata.alpha ?: FirmwareRelease(),
)
val environmentState =
EnvironmentMetricsState(environmentMetrics = logs.telemetry.filter { it.hasValidEnvironmentMetrics() })
val availableLogs = buildSet {
if (metricsState.hasDeviceMetrics()) add(LogsType.DEVICE)
if (metricsState.hasPositionLogs()) {
add(LogsType.NODE_MAP)
add(LogsType.POSITIONS)
}
if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT)
if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL)
if (metricsState.hasPowerMetrics()) add(LogsType.POWER)
if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE)
if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO)
if (metricsState.hasHostMetrics()) add(LogsType.HOST)
if (metricsState.hasPaxMetrics()) add(LogsType.PAX)
}
NodeDetailUiState(
node = node,
ourNode = identity.ourNode,
metricsState = metricsState,
environmentState = environmentState,
availableLogs = availableLogs,
lastTracerouteTime = metadata.trTime,
lastRequestNeighborsTime = metadata.niTime,
)
}
}
private data class LogsGroup(
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val posPackets: List<MeshPacket>,
val pax: List<MeshLog>,
val trRes: List<MeshLog>,
val niRes: List<MeshLog>,
)
private data class IdentityGroup(val ourNode: Node?, val myInfo: MyNodeInfo?, val profile: DeviceProfile)
private data class MetadataGroup(
val edition: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val trTime: Long?,
val niTime: Long?,
)
val effects: SharedFlow<NodeRequestEffect> = nodeRequestActions.effects
fun start(nodeId: Int) {
@@ -286,6 +288,7 @@ constructor(
}
}
/** Dispatches high-level node management actions like removal, muting, or favoriting. */
fun handleNodeMenuAction(action: NodeMenuAction) {
when (action) {
is NodeMenuAction.Remove -> nodeManagementActions.requestRemoveNode(viewModelScope, action.node)
@@ -321,41 +324,10 @@ constructor(
nodeManagementActions.setNodeNotes(viewModelScope, nodeNum, notes)
}
/** Returns the type-safe navigation route for a direct message to this node. */
fun getDirectMessageRoute(node: Node, ourNode: Node?): String {
val hasPKC = ourNode?.hasPKC == true
val channel = if (hasPKC) DataPacket.PKC_CHANNEL_INDEX else node.channel
return "${channel}${node.user.id}"
}
@Suppress("MagicNumber")
private suspend fun createFallbackNode(nodeNum: Int): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(4, '0').takeLast(4)
val longName = "${getString(Res.string.fallback_node_name)}_$safeUserId"
val defaultUser =
User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET)
return Node(num = nodeNum, user = defaultUser)
}
}
private data class NodeDetailUiStateData(
val nodeId: Int,
val actualNode: Node,
val ourNode: Node?,
val ourNodeNum: Int?,
val myInfo: MyNodeInfo?,
val profile: org.meshtastic.proto.DeviceProfile,
val telemetry: List<Telemetry>,
val packets: List<MeshPacket>,
val positionPackets: List<MeshPacket>,
val paxLogs: List<MeshLog>,
val tracerouteRequests: List<MeshLog>,
val tracerouteResults: List<MeshLog>,
val neighborInfoRequests: List<MeshLog>,
val neighborInfoResults: List<MeshLog>,
val firmwareEditionArg: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val lastTracerouteTime: Long?,
val lastRequestNeighborsTime: Long?,
)

View File

@@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.flow.update
@@ -52,10 +53,10 @@ import org.meshtastic.core.data.repository.TracerouteSnapshotRepository
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.TelemetryType
import org.meshtastic.core.model.evaluateTracerouteMapAvailability
import org.meshtastic.core.model.util.UnitConversions
import org.meshtastic.core.model.util.hasValidEnvironmentMetrics
import org.meshtastic.core.model.util.nowSeconds
import org.meshtastic.core.model.util.toDate
import org.meshtastic.core.model.util.toInstant
@@ -76,11 +77,9 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.TimeFrame
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
@@ -90,15 +89,11 @@ import java.util.Locale
import javax.inject.Inject
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 && ((rx_snr ?: 0f) != 0f || (rx_rssi ?: 0) != 0)
private fun Telemetry.hasValidEnvironmentMetrics(): Boolean {
val metrics = this.environment_metrics ?: return false
return metrics.relative_humidity != null && metrics.temperature != null && metrics.temperature?.isNaN() != true
}
private fun MeshPacket.hasValidSignal(): Boolean = rx_time > 0 && (rx_snr != 0f || rx_rssi != 0)
/**
* ViewModel responsible for managing and graphing metrics (telemetry, signal strength, paxcount) for a specific node.
*/
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
class MetricsViewModel
@@ -124,30 +119,11 @@ constructor(
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
private fun MeshLog.hasValidTraceroute(dest: Int?): Boolean =
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest }
private fun MeshLog.hasValidNeighborInfo(dest: Int?): Boolean =
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == dest }
/**
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
* freezing when viewing unknown nodes.
*/
private suspend fun createFallbackNode(nodeNum: Int): Node {
val userId = DataPacket.nodeNumToDefaultId(nodeNum)
val safeUserId = userId.padStart(DEFAULT_ID_SUFFIX_LENGTH, '0').takeLast(DEFAULT_ID_SUFFIX_LENGTH)
val longName = getString(Res.string.fallback_node_name) + " $safeUserId"
val defaultUser =
User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET)
return Node(num = nodeNum, user = defaultUser)
}
fun getUser(nodeNum: Int) = nodeRepository.getUser(nodeNum)
fun deleteLog(uuid: String) = viewModelScope.launch(dispatchers.io) { meshLogRepository.deleteLog(uuid) }
/** Returns the map overlay for a specific traceroute request ID. */
fun getTracerouteOverlay(requestId: Int): TracerouteOverlay? {
val cached = tracerouteOverlayCache.value[requestId]
if (cached != null) return cached
@@ -176,7 +152,9 @@ constructor(
fun clearTracerouteResponse() = serviceRepository.clearTracerouteResponse()
fun positionedNodeNums(): Set<Int> =
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.map { it.num }.toSet()
nodeRepository.nodeDBbyNum.value.values.filter { it.validPosition != null }.numSet()
private fun List<Node>.numSet(): Set<Int> = map { it.num }.toSet()
init {
viewModelScope.launch {
@@ -199,13 +177,18 @@ constructor(
}
private val _state = MutableStateFlow(MetricsState.Empty)
/** Current aggregated metrics state, including signal history and sensor logs. */
val state: StateFlow<MetricsState> = _state
private val environmentState = MutableStateFlow(EnvironmentMetricsState())
private val _timeFrame = MutableStateFlow(TimeFrame.TWENTY_FOUR_HOURS)
/** The active time window for filtering graphed data. */
val timeFrame: StateFlow<TimeFrame> = _timeFrame
/** Returns the list of time frames that are actually available based on the oldest data point. */
val availableTimeFrames: StateFlow<List<TimeFrame>> =
combine(_state, environmentState) { state, envState ->
val stateOldest = state.oldestTimestampSeconds()
@@ -295,6 +278,7 @@ constructor(
)
}
/** Shows the detail dialog for a traceroute result, with an option to view on the map. */
fun showTracerouteDetail(
annotatedMessage: AnnotatedString,
requestId: Int,
@@ -352,6 +336,8 @@ constructor(
jobs =
viewModelScope.launch {
if (currentDestNum != null) {
val logNodeIdFlow = nodeRepository.effectiveLogNodeId(currentDestNum)
launch {
combine(nodeRepository.nodeDBbyNum, nodeRepository.myNodeInfo) { nodes, myInfo ->
nodes[currentDestNum] to (nodes.keys.firstOrNull() to myInfo)
@@ -360,7 +346,9 @@ constructor(
.collect { (node, localData) ->
val (ourNodeNum, myInfo) = localData
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(currentDestNum)
val actualNode =
node
?: Node.createFallback(currentDestNum, getString(Res.string.fallback_node_name))
val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null
val hwModel = actualNode.user.hw_model.value
val deviceHardware =
@@ -394,44 +382,47 @@ constructor(
}
launch {
meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
val device = mutableListOf<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
logNodeIdFlow
.flatMapLatest { meshLogRepository.getTelemetryFrom(it) }
.collect { telemetry ->
val device = mutableListOf<Telemetry>()
val power = mutableListOf<Telemetry>()
val host = mutableListOf<Telemetry>()
val env = mutableListOf<Telemetry>()
for (item in telemetry) {
if (item.device_metrics != null) device.add(item)
if (item.power_metrics != null) power.add(item)
if (item.host_metrics != null) host.add(item)
if (item.hasValidEnvironmentMetrics()) env.add(item)
}
for (item in telemetry) {
if (item.device_metrics != null) device.add(item)
if (item.power_metrics != null) power.add(item)
if (item.host_metrics != null) host.add(item)
if (item.hasValidEnvironmentMetrics()) env.add(item)
}
_state.update { state ->
state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host)
_state.update { state ->
state.copy(deviceMetrics = device, powerMetrics = power, hostMetrics = host)
}
environmentState.update { it.copy(environmentMetrics = env) }
}
environmentState.update { it.copy(environmentMetrics = env) }
}
}
launch {
meshLogRepository.getMeshPacketsFrom(currentDestNum).collect { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
logNodeIdFlow
.flatMapLatest { meshLogRepository.getMeshPacketsFrom(it) }
.collect { meshPackets ->
_state.update { state ->
state.copy(signalMetrics = meshPackets.filter { it.hasValidSignal() })
}
}
}
}
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP.value),
meshLogRepository.getRequestLogs(currentDestNum, PortNum.TRACEROUTE_APP),
logNodeIdFlow.flatMapLatest {
meshLogRepository.getLogsFrom(it, PortNum.TRACEROUTE_APP.value)
},
) { request, response ->
_state.update { state ->
state.copy(
tracerouteRequests = request.filter { it.hasValidTraceroute(currentDestNum) },
tracerouteResults = response,
)
state.copy(tracerouteRequests = request, tracerouteResults = response)
}
}
.collect {}
@@ -439,42 +430,39 @@ constructor(
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP.value),
meshLogRepository.getRequestLogs(currentDestNum, PortNum.NEIGHBORINFO_APP),
logNodeIdFlow.flatMapLatest {
meshLogRepository.getLogsFrom(it, PortNum.NEIGHBORINFO_APP.value)
},
) { request, response ->
_state.update { state ->
state.copy(
neighborInfoRequests =
request.filter { it.hasValidNeighborInfo(currentDestNum) },
neighborInfoResults = response,
)
state.copy(neighborInfoRequests = request, neighborInfoResults = response)
}
}
.collect {}
}
launch {
meshLogRepository.getMeshPacketsFrom(
currentDestNum,
PortNum.POSITION_APP.value,
).collect { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
logNodeIdFlow
.flatMapLatest { meshLogRepository.getMeshPacketsFrom(it, PortNum.POSITION_APP.value) }
.collect { packets ->
val distinctPositions =
packets
.mapNotNull { it.toPosition() }
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
}
}
launch {
meshLogRepository.getLogsFrom(currentDestNum, PortNum.PAXCOUNTER_APP.value).collect { logs ->
_state.update { state -> state.copy(paxMetrics = logs) }
}
logNodeIdFlow
.flatMapLatest { meshLogRepository.getLogsFrom(it, PortNum.PAXCOUNTER_APP.value) }
.collect { logs -> _state.update { state -> state.copy(paxMetrics = logs) } }
}
launch {
@@ -558,13 +546,15 @@ constructor(
val packet = log.fromRadio.packet
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
// Requests for paxcount (want_response = true) should not be logged as data points.
if (decoded.want_response == true) return null
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) return pax
}
} catch (e: IOException) {
Logger.e(e) { "Failed to parse Paxcount from binary data" }
}
// Fallback: Try direct base64 or bytes from raw_message
// Fallback: Attempt to parse Paxcount from raw_message as base64 or hex string.
try {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {