mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-03-28 10:42:31 -04:00
feat: Accurately display outgoing diagnostic packets (#4569)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
|
||||
@@ -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]+$"))) {
|
||||
|
||||
Reference in New Issue
Block a user