feat(wire): migrate from protobuf -> wire (#4401)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-03 18:01:12 -06:00 committed by GitHub
parent 9dbc8b7fbf
commit 25657e8f8f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
239 changed files with 7149 additions and 6144 deletions

View file

@ -1,11 +1,16 @@
<?xml version="1.0" ?>
<?xml version='1.0' encoding='UTF-8'?>
<SmellBaseline>
<ManuallySuppressedIssues/>
<CurrentIssues>
<ID>CommentWrapping:SignalMetrics.kt$Metric.SNR$/* Selected 12 as the max to get 4 equal vertical sections. */</ID>
<ID>LongMethod:EnvironmentMetrics.kt$@Composable fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateUp: () -&gt; Unit)</ID>
<ID>CyclomaticComplexMethod:CompassViewModel.kt$CompassViewModel$@Suppress("ReturnCount") private fun calculatePositionalAccuracyMeters(): Float?</ID>
<ID>CyclomaticComplexMethod:DeviceMetrics.kt$@Suppress("LongMethod") @Composable private fun DeviceMetricsChart( modifier: Modifier = Modifier, telemetries: List&lt;Telemetry>, legendData: List&lt;LegendData>, vicoScrollState: VicoScrollState, selectedX: Double?, onPointSelected: (Double) -> Unit, )</ID>
<ID>CyclomaticComplexMethod:NodeDetailActions.kt$NodeDetailActions$fun handleNodeMenuAction(scope: CoroutineScope, action: NodeMenuAction)</ID>
<ID>CyclomaticComplexMethod:NodeDetailViewModel.kt$NodeDetailViewModel$fun handleNodeMenuAction(action: NodeMenuAction)</ID>
<ID>CyclomaticComplexMethod:PowerMetrics.kt$@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit)</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1000L</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-5</ID>
<ID>MagicNumber:MetricsViewModel.kt$MetricsViewModel$1e-7</ID>
<ID>TooGenericExceptionCaught:PaxMetrics.kt$e: Exception</ID>
<ID>UnusedPrivateProperty:NodeDetailScreen.kt$val loadingMessage = stringResource(Res.string.loading)</ID>
</CurrentIssues>
</SmellBaseline>

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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.feature.node.component
import androidx.compose.foundation.isSystemInDarkTheme
@ -69,7 +68,7 @@ internal fun InlineMap(node: Node, modifier: Modifier = Modifier) {
),
cameraPositionState = cameraState,
) {
val precisionMeters = precisionBitsToMeters(node.position.precisionBits)
val precisionMeters = precisionBitsToMeters(node.position.precision_bits ?: 0)
val latLng = LatLng(node.latitude, node.longitude)
if (precisionMeters > 0) {
Circle(

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,11 +14,10 @@
* 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.feature.node.compass
import androidx.compose.ui.graphics.Color
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config
private const val DEFAULT_TARGET_COLOR_HEX = 0xFFFF9800
@ -44,6 +43,6 @@ data class CompassUiState(
val angularErrorDeg: Float? = null,
val isAligned: Boolean = false,
val hasTargetPosition: Boolean = true,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
val displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
val targetAltitude: Int? = null,
)

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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.feature.node.compass
import android.hardware.GeomagneticField
@ -36,7 +35,8 @@ import org.meshtastic.core.model.util.bearing
import org.meshtastic.core.model.util.latLongToMeter
import org.meshtastic.core.model.util.toDistanceString
import org.meshtastic.core.ui.component.precisionBitsToMeters
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
import javax.inject.Inject
import kotlin.math.abs
import kotlin.math.atan2
@ -68,17 +68,18 @@ constructor(
private var updatesJob: Job? = null
private var targetPosition: Pair<Double, Double>? = null
private var targetPositionProto: org.meshtastic.proto.MeshProtos.Position? = null
private var targetPositionProto: Position? = null
private var targetPositionTimeSec: Long? = null
fun start(node: Node, displayUnits: DisplayUnits) {
fun start(node: Node, displayUnits: Config.DisplayConfig.DisplayUnits) {
val targetPos = node.validPosition?.let { node.latitude to node.longitude }
targetPosition = targetPos
targetPositionProto = node.position
val targetColor = Color(node.colors.second)
val targetName = node.user.longName.ifBlank { node.user.shortName.ifBlank { node.num.toString() } }
val targetName =
(node.user.long_name ?: "").ifBlank { (node.user.short_name ?: "").ifBlank { node.num.toString() } }
targetPositionTimeSec =
node.position.timestamp.takeIf { it > 0 }?.toLong() ?: node.position.time.takeIf { it > 0 }?.toLong()
node.position.timestamp?.takeIf { it > 0 }?.toLong() ?: node.position.time?.takeIf { it > 0 }?.toLong()
_uiState.update {
it.copy(
@ -216,17 +217,16 @@ constructor(
val positionTime = targetPositionTimeSec
if (positionTime == null || positionTime <= 0) return null
val gpsAccuracyMm = position.gpsAccuracy.toFloat()
val gpsAccuracyMm = (position.gps_accuracy ?: 0).toFloat()
val pdop = position.PDOP ?: 0
val hdop = position.HDOP ?: 0
val vdop = position.VDOP ?: 0
val dop: Float? =
when {
position.getPDOP() > 0 -> position.getPDOP() / HUNDRED
position.getHDOP() > 0 && position.getVDOP() > 0 ->
sqrt(
(position.getHDOP() / HUNDRED).toDouble().pow(2.0) +
(position.getVDOP() / HUNDRED).toDouble().pow(2.0),
)
.toFloat()
position.getHDOP() > 0 -> position.getHDOP() / HUNDRED
pdop > 0 -> pdop / HUNDRED
hdop > 0 && vdop > 0 ->
sqrt((hdop / HUNDRED).toDouble().pow(2.0) + (vdop / HUNDRED).toDouble().pow(2.0)).toFloat()
hdop > 0 -> hdop / HUNDRED
else -> null
}
@ -235,8 +235,9 @@ constructor(
}
// Fallback: infer radius from precision bits if provided
if (position.precisionBits > 0) {
return precisionBitsToMeters(position.precisionBits).toFloat()
val precisionBits = position.precision_bits ?: 0
if (precisionBits > 0) {
return precisionBitsToMeters(precisionBits).toFloat()
}
return null

View file

@ -49,7 +49,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.core.ui.theme.StatusColors.StatusYellow
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.FirmwareEdition
@Composable
fun AdministrationSection(
@ -82,7 +82,7 @@ fun AdministrationSection(
}
}
val firmwareVersion = node.metadata?.firmwareVersion
val firmwareVersion = node.metadata?.firmware_version
val firmwareEdition = metricsState.firmwareEdition
if (firmwareVersion != null || (firmwareEdition != null && metricsState.isLocal)) {
FirmwareSection(metricsState, firmwareEdition, firmwareVersion, onFirmwareSelect)
@ -92,7 +92,7 @@ fun AdministrationSection(
@Composable
private fun FirmwareSection(
metricsState: MetricsState,
firmwareEdition: MeshProtos.FirmwareEdition?,
firmwareEdition: FirmwareEdition?,
firmwareVersion: String?,
onFirmwareSelect: (FirmwareRelease) -> Unit,
) {
@ -101,7 +101,7 @@ private fun FirmwareSection(
firmwareEdition?.let { edition ->
val icon =
when (edition) {
MeshProtos.FirmwareEdition.VANILLA -> Icons.Rounded.Icecream
FirmwareEdition.VANILLA -> Icons.Rounded.Icecream
else -> Icons.Rounded.ForkLeft
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,7 +14,6 @@
* 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.feature.node.component
import androidx.compose.material3.MaterialTheme
@ -30,13 +29,13 @@ import org.meshtastic.core.strings.altitude
import org.meshtastic.core.strings.elevation_suffix
import org.meshtastic.core.ui.icon.Elevation
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config
@Composable
fun ElevationInfo(
modifier: Modifier = Modifier,
altitude: Int,
system: DisplayUnits,
system: Config.DisplayConfig.DisplayUnits,
suffix: String = stringResource(Res.string.elevation_suffix),
contentColor: Color = MaterialTheme.colorScheme.onSurface,
) {
@ -52,5 +51,5 @@ fun ElevationInfo(
@Composable
@Preview
private fun ElevationInfoPreview() {
MaterialTheme { ElevationInfo(altitude = 100, system = DisplayUnits.METRIC, suffix = "ASL") }
MaterialTheme { ElevationInfo(altitude = 100, system = Config.DisplayConfig.DisplayUnits.METRIC, suffix = "ASL") }
}

View file

@ -59,92 +59,78 @@ import org.meshtastic.core.strings.weight
import org.meshtastic.core.strings.wind
import org.meshtastic.feature.node.model.DrawableMetricInfo
import org.meshtastic.feature.node.model.VectorMetricInfo
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.Config
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
internal fun EnvironmentMetrics(
node: Node,
displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits,
displayUnits: Config.DisplayConfig.DisplayUnits,
isFahrenheit: Boolean = false,
) {
val vectorMetrics =
remember(node.environmentMetrics, isFahrenheit, displayUnits) {
buildList {
with(node.environmentMetrics) {
if (!temperature.isNaN()) {
add(
VectorMetricInfo(
Res.string.temperature,
temperature.toTempString(isFahrenheit),
Icons.Rounded.Thermostat,
),
)
temperature?.let { temp ->
if (!temp.isNaN()) {
add(
VectorMetricInfo(
Res.string.temperature,
temp.toTempString(isFahrenheit),
Icons.Rounded.Thermostat,
),
)
}
}
if (hasRelativeHumidity()) {
add(
VectorMetricInfo(
Res.string.humidity,
"%.0f%%".format(relativeHumidity),
Icons.Rounded.WaterDrop,
),
)
relative_humidity?.let { rh ->
add(VectorMetricInfo(Res.string.humidity, "%.0f%%".format(rh), Icons.Rounded.WaterDrop))
}
if (hasBarometricPressure()) {
add(
VectorMetricInfo(
Res.string.pressure,
"%.0f hPa".format(barometricPressure),
Icons.Rounded.Speed,
),
)
barometric_pressure?.let { bp ->
add(VectorMetricInfo(Res.string.pressure, "%.0f hPa".format(bp), Icons.Rounded.Speed))
}
if (hasGasResistance()) {
add(
VectorMetricInfo(
Res.string.gas_resistance,
"%.0f MΩ".format(gasResistance),
Icons.Rounded.BlurOn,
),
)
gas_resistance?.let { gr ->
add(VectorMetricInfo(Res.string.gas_resistance, "%.0f MΩ".format(gr), Icons.Rounded.BlurOn))
}
if (hasVoltage()) {
add(VectorMetricInfo(Res.string.voltage, "%.2fV".format(voltage), Icons.Rounded.Bolt))
voltage?.let { v ->
add(VectorMetricInfo(Res.string.voltage, "%.2fV".format(v), Icons.Rounded.Bolt))
}
if (hasCurrent()) {
add(VectorMetricInfo(Res.string.current, "%.1fmA".format(current), Icons.Rounded.Power))
current?.let { c ->
add(VectorMetricInfo(Res.string.current, "%.1fmA".format(c), Icons.Rounded.Power))
}
if (hasIaq()) add(VectorMetricInfo(Res.string.iaq, iaq.toString(), Icons.Rounded.Air))
if (hasDistance()) {
iaq?.let { i -> add(VectorMetricInfo(Res.string.iaq, i.toString(), Icons.Rounded.Air)) }
distance?.let { d ->
add(
VectorMetricInfo(
Res.string.distance,
distance.toSmallDistanceString(displayUnits),
d.toSmallDistanceString(displayUnits),
Icons.Rounded.Height,
),
)
}
if (hasLux()) add(VectorMetricInfo(Res.string.lux, "%.0f lx".format(lux), Icons.Rounded.LightMode))
if (hasUvLux()) {
add(VectorMetricInfo(Res.string.uv_lux, "%.0f lx".format(uvLux), Icons.Rounded.LightMode))
lux?.let { l ->
add(VectorMetricInfo(Res.string.lux, "%.0f lx".format(l), Icons.Rounded.LightMode))
}
if (hasWindSpeed()) {
uv_lux?.let { uvl ->
add(VectorMetricInfo(Res.string.uv_lux, "%.0f lx".format(uvl), Icons.Rounded.LightMode))
}
wind_speed?.let { ws ->
@Suppress("MagicNumber")
val normalizedBearing = (windDirection + 180) % 360
val normalizedBearing = ((wind_direction ?: 0) + 180) % 360
add(
VectorMetricInfo(
Res.string.wind,
windSpeed.toSpeedString(displayUnits),
ws.toFloat().toSpeedString(displayUnits),
Icons.Outlined.Navigation,
normalizedBearing.toFloat(),
),
)
}
if (hasWeight()) {
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(weight), Icons.Rounded.Scale))
weight?.let { w ->
add(VectorMetricInfo(Res.string.weight, "%.2f kg".format(w), Icons.Rounded.Scale))
}
if (hasTemperature() && hasRelativeHumidity()) {
val dewPoint = UnitConversions.calculateDewPoint(temperature, relativeHumidity)
if (temperature != null && relative_humidity != null) {
val dewPoint = UnitConversions.calculateDewPoint(temperature!!, relative_humidity!!)
if (!dewPoint.isNaN()) {
add(
DrawableMetricInfo(
@ -155,29 +141,31 @@ internal fun EnvironmentMetrics(
)
}
}
if (hasSoilTemperature() && !soilTemperature.isNaN()) {
add(
DrawableMetricInfo(
Res.string.soil_temperature,
soilTemperature.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.soil_temperature,
),
)
soil_temperature?.let { st ->
if (!st.isNaN()) {
add(
DrawableMetricInfo(
Res.string.soil_temperature,
st.toTempString(isFahrenheit),
org.meshtastic.feature.node.R.drawable.soil_temperature,
),
)
}
}
if (hasSoilMoisture()) {
soil_moisture?.let { sm ->
add(
DrawableMetricInfo(
Res.string.soil_moisture,
"%d%%".format(soilMoisture),
"%d%%".format(sm),
org.meshtastic.feature.node.R.drawable.soil_moisture,
),
)
}
if (hasRadiation()) {
radiation?.let { r ->
add(
DrawableMetricInfo(
Res.string.radiation,
"%.1f µR/h".format(radiation),
"%.1f µR/h".format(r),
org.meshtastic.feature.node.R.drawable.ic_filled_radioactive_24,
),
)

View file

@ -52,12 +52,15 @@ import org.meshtastic.core.ui.component.BasicListItem
import org.meshtastic.core.ui.component.icon
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatAgo
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config
import java.net.URLEncoder
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.METRIC) {
fun LinkedCoordinatesItem(
node: Node,
displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
) {
val context = LocalContext.current
val clipboard: Clipboard = LocalClipboard.current
val coroutineScope = rememberCoroutineScope()
@ -91,7 +94,7 @@ fun LinkedCoordinatesItem(node: Node, displayUnits: DisplayUnits = DisplayUnits.
supportingText = "$ago$coordinates$elevationText",
trailingContent = Icons.AutoMirrored.Rounded.KeyboardArrowRight.icon(),
onClick = {
val label = URLEncoder.encode(node.user.longName, "utf-8")
val label = URLEncoder.encode(node.user.long_name ?: "", "utf-8")
val uri = "geo:0,0?q=${node.latitude},${node.longitude}&z=17&label=$label".toUri()
val intent = Intent(Intent.ACTION_VIEW, uri).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }

View file

@ -148,8 +148,8 @@ private fun MainNodeDetails(node: Node) {
SectionDivider()
MqttAndVerificationRow(node)
}
val publicKey = node.publicKey ?: node.user.publicKey
if (!publicKey.isEmpty) {
val publicKey = node.publicKey ?: node.user.public_key
if (publicKey != null && publicKey.size > 0) {
SectionDivider()
PublicKeyItem(publicKey.toByteArray())
}
@ -161,13 +161,13 @@ private fun NameAndRoleRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.short_name),
value = node.user.shortName.ifEmpty { "???" },
value = (node.user.short_name ?: "").ifEmpty { "???" },
icon = MeshtasticIcons.Person,
modifier = Modifier.weight(1f),
)
InfoItem(
label = stringResource(Res.string.role),
value = node.user.role.name,
value = node.user.role?.name ?: "",
icon = MeshtasticIcons.Role,
modifier = Modifier.weight(1f),
)
@ -219,14 +219,14 @@ private fun UserAndUptimeRow(node: Node) {
Row(modifier = Modifier.fillMaxWidth()) {
InfoItem(
label = stringResource(Res.string.user_id),
value = node.user.id,
value = node.user.id ?: "",
icon = MeshtasticIcons.Person,
modifier = Modifier.weight(1f),
)
if (node.deviceMetrics.uptimeSeconds > 0) {
if ((node.deviceMetrics.uptime_seconds ?: 0) > 0) {
InfoItem(
label = stringResource(Res.string.uptime),
value = formatUptime(node.deviceMetrics.uptimeSeconds),
value = formatUptime(node.deviceMetrics.uptime_seconds!!),
icon = MeshtasticIcons.ArrowCircleUp,
modifier = Modifier.weight(1f),
)

View file

@ -73,7 +73,7 @@ import org.meshtastic.core.ui.component.SoilTemperatureInfo
import org.meshtastic.core.ui.component.TemperatureInfo
import org.meshtastic.core.ui.component.preview.NodePreviewParameterProvider
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig
import org.meshtastic.proto.Config
private const val ACTIVE_ALPHA = 0.5f
private const val INACTIVE_ALPHA = 0.2f
@ -95,7 +95,7 @@ fun NodeItem(
val isFavorite = remember(thatNode) { thatNode.isFavorite }
val isMuted = remember(thatNode) { thatNode.isMuted }
val isIgnored = thatNode.isIgnored
val originalLongName = thatNode.user.longName.ifEmpty { stringResource(Res.string.unknown_username) }
val originalLongName = (thatNode.user.long_name ?: "").ifEmpty { stringResource(Res.string.unknown_username) }
@Suppress("MagicNumber")
val longName =
@ -107,7 +107,10 @@ fun NodeItem(
}
}
val isThisNode = remember(thatNode) { thisNode?.num == thatNode.num }
val system = remember(distanceUnits) { DisplayConfig.DisplayUnits.forNumber(distanceUnits) }
val system =
remember(distanceUnits) {
Config.DisplayConfig.DisplayUnits.fromValue(distanceUnits) ?: Config.DisplayConfig.DisplayUnits.METRIC
}
val distance =
remember(thisNode, thatNode) { thisNode?.distance(thatNode)?.takeIf { it > 0 }?.toDistanceString(system) }
@ -135,7 +138,7 @@ fun NodeItem(
val unmessageable =
remember(thatNode) {
when {
thatNode.user.hasIsUnmessagable() -> thatNode.user.isUnmessagable
thatNode.user.is_unmessagable != null -> thatNode.user.is_unmessagable!!
else -> thatNode.user.role.isUnmessageableRole()
}
}
@ -190,7 +193,7 @@ private fun NodeItemHeader(
NodeKeyStatusIcon(
hasPKC = thatNode.hasPKC,
mismatchKey = thatNode.mismatchKey,
publicKey = thatNode.user.publicKey,
publicKey = thatNode.user.public_key,
modifier = Modifier.size(32.dp),
)
Text(
@ -216,7 +219,7 @@ private fun NodeItemHeader(
private fun NodeItemMetrics(
thatNode: Node,
distance: String?,
system: DisplayConfig.DisplayUnits,
system: Config.DisplayConfig.DisplayUnits,
contentColor: Color,
) {
Row(
@ -224,8 +227,12 @@ private fun NodeItemMetrics(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (thatNode.batteryLevel > 0 || thatNode.voltage > 0f) {
MaterialBatteryInfo(level = thatNode.batteryLevel, voltage = thatNode.voltage, contentColor = contentColor)
if ((thatNode.batteryLevel ?: 0) > 0 || (thatNode.voltage ?: 0f) > 0f) {
MaterialBatteryInfo(
level = thatNode.batteryLevel ?: 0,
voltage = thatNode.voltage ?: 0f,
contentColor = contentColor,
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
@ -235,13 +242,13 @@ private fun NodeItemMetrics(
}
thatNode.validPosition?.let { position ->
ElevationInfo(
altitude = position.altitude,
altitude = position.altitude ?: 0,
system = system,
suffix = stringResource(Res.string.elevation_suffix),
contentColor = contentColor,
)
}
val satCount = thatNode.validPosition?.satsInView ?: 0
val satCount = thatNode.validPosition?.sats_in_view ?: 0
if (satCount > 0) {
SatelliteCountInfo(satCount = satCount, contentColor = contentColor)
}
@ -255,49 +262,49 @@ private fun NodeItemMetrics(
private fun NodeItemEnvironment(thatNode: Node, tempInFahrenheit: Boolean, contentColor: Color) {
val env = thatNode.environmentMetrics
val pax = thatNode.paxcounter
if (thatNode.hasEnvironmentMetrics || pax.ble != 0 || pax.wifi != 0) {
if (thatNode.hasEnvironmentMetrics || (pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (pax.ble != 0 || pax.wifi != 0) {
PaxcountInfo(pax = "B:${pax.ble} W:${pax.wifi}", contentColor = contentColor)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0) {
PaxcountInfo(pax = "B:${pax.ble ?: 0} W:${pax.wifi ?: 0}", contentColor = contentColor)
}
if (env.temperature != 0f) {
if ((env.temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(env.temperature))
"%.1f°F".format(celsiusToFahrenheit(env.temperature ?: 0f))
} else {
"%.1f°C".format(env.temperature)
"%.1f°C".format(env.temperature ?: 0f)
}
TemperatureInfo(temp = temp, contentColor = contentColor)
}
if (env.relativeHumidity != 0f) {
HumidityInfo(humidity = "%.0f%%".format(env.relativeHumidity), contentColor = contentColor)
if ((env.relative_humidity ?: 0f) != 0f) {
HumidityInfo(humidity = "%.0f%%".format(env.relative_humidity ?: 0f), contentColor = contentColor)
}
if (env.barometricPressure != 0f) {
PressureInfo(pressure = "%.1fhPa".format(env.barometricPressure), contentColor = contentColor)
if ((env.barometric_pressure ?: 0f) != 0f) {
PressureInfo(pressure = "%.1fhPa".format(env.barometric_pressure ?: 0f), contentColor = contentColor)
}
if (env.soilTemperature != 0f) {
if ((env.soil_temperature ?: 0f) != 0f) {
val temp =
if (tempInFahrenheit) {
"%.1f°F".format(celsiusToFahrenheit(env.soilTemperature))
"%.1f°F".format(celsiusToFahrenheit(env.soil_temperature ?: 0f))
} else {
"%.1f°C".format(env.soilTemperature)
"%.1f°C".format(env.soil_temperature ?: 0f)
}
SoilTemperatureInfo(temp = temp, contentColor = contentColor)
}
if (env.soilMoisture != 0 && env.soilTemperature != 0f) {
SoilMoistureInfo(moisture = "${env.soilMoisture}%", contentColor = contentColor)
if ((env.soil_moisture ?: 0) != 0 && (env.soil_temperature ?: 0f) != 0f) {
SoilMoistureInfo(moisture = "${env.soil_moisture}%", contentColor = contentColor)
}
if (env.voltage != 0f) {
PowerInfo(value = "%.2fV".format(env.voltage), contentColor = contentColor)
if ((env.voltage ?: 0f) != 0f) {
PowerInfo(value = "%.2fV".format(env.voltage ?: 0f), contentColor = contentColor)
}
if (env.current != 0f) {
PowerInfo(value = "%.1fmA".format(env.current), contentColor = contentColor)
if ((env.current ?: 0f) != 0f) {
PowerInfo(value = "%.1fmA".format(env.current ?: 0f), contentColor = contentColor)
}
if (env.iaq != 0) {
if ((env.iaq ?: 0) != 0) {
AirQualityInfo(iaq = "${env.iaq}", contentColor = contentColor)
}
}
@ -311,9 +318,9 @@ private fun NodeItemFooter(thatNode: Node, contentColor: Color) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
HardwareInfo(hwModel = thatNode.user.hwModel.name, contentColor = contentColor)
RoleInfo(role = thatNode.user.role.name, contentColor = contentColor)
NodeIdInfo(id = thatNode.user.id.ifEmpty { "???" }, contentColor = contentColor)
HardwareInfo(hwModel = thatNode.user.hw_model?.name ?: "", contentColor = contentColor)
RoleInfo(role = thatNode.user.role?.name ?: "", contentColor = contentColor)
NodeIdInfo(id = (thatNode.user.id ?: "").ifEmpty { "???" }, contentColor = contentColor)
}
}

View file

@ -54,7 +54,7 @@ fun NodeActionDialogs(
text =
stringResource(
if (node.isFavorite) Res.string.favorite_remove else Res.string.favorite_add,
node.user.longName,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
@ -69,7 +69,7 @@ fun NodeActionDialogs(
text =
stringResource(
if (node.isIgnored) Res.string.ignore_remove else Res.string.ignore_add,
node.user.longName,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
@ -82,7 +82,10 @@ fun NodeActionDialogs(
SimpleAlertDialog(
title = if (node.isMuted) Res.string.unmute else Res.string.mute_notifications,
text =
stringResource(if (node.isMuted) Res.string.mute_remove else Res.string.mute_add, node.user.longName),
stringResource(
if (node.isMuted) Res.string.mute_remove else Res.string.mute_add,
node.user.long_name ?: "",
),
onConfirm = {
onDismissMenuRequest()
onConfirmMute(node)
@ -93,7 +96,7 @@ fun NodeActionDialogs(
if (displayRemoveDialog) {
SimpleAlertDialog(
title = Res.string.remove,
text = Res.string.remove_node_text,
text = stringResource(Res.string.remove_node_text),
onConfirm = {
onDismissMenuRequest()
onConfirmRemove(node)

View file

@ -55,7 +55,7 @@ import org.meshtastic.core.strings.position
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.feature.node.model.NodeDetailAction
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config
private const val EXCHANGE_BUTTON_WEIGHT = 1.1f
private const val COMPASS_BUTTON_WEIGHT = 0.9f
@ -148,7 +148,7 @@ private fun PositionMap(node: Node, distance: String?) {
private fun PositionActionButtons(
node: Node,
hasValidPosition: Boolean,
displayUnits: DisplayUnits,
displayUnits: Config.DisplayConfig.DisplayUnits,
onAction: (NodeDetailAction) -> Unit,
) {
Row(

View file

@ -46,17 +46,17 @@ internal fun PowerMetrics(node: Node) {
remember(node.powerMetrics) {
buildList {
with(node.powerMetrics) {
if (ch1Voltage != 0f) {
add(VectorMetricInfo(Res.string.channel_1, "%.2fV".format(ch1Voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_1, "%.1fmA".format(ch1Current), Icons.Rounded.Power))
if ((ch1_voltage ?: 0f) != 0f) {
add(VectorMetricInfo(Res.string.channel_1, "%.2fV".format(ch1_voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_1, "%.1fmA".format(ch1_current), Icons.Rounded.Power))
}
if (ch2Voltage != 0f) {
add(VectorMetricInfo(Res.string.channel_2, "%.2fV".format(ch2Voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_2, "%.1fmA".format(ch2Current), Icons.Rounded.Power))
if ((ch2_voltage ?: 0f) != 0f) {
add(VectorMetricInfo(Res.string.channel_2, "%.2fV".format(ch2_voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_2, "%.1fmA".format(ch2_current), Icons.Rounded.Power))
}
if (ch3Voltage != 0f) {
add(VectorMetricInfo(Res.string.channel_3, "%.2fV".format(ch3Voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_3, "%.1fmA".format(ch3Current), Icons.Rounded.Power))
if ((ch3_voltage ?: 0f) != 0f) {
add(VectorMetricInfo(Res.string.channel_3, "%.2fV".format(ch3_voltage), Icons.Rounded.Bolt))
add(VectorMetricInfo(Res.string.channel_3, "%.1fmA".format(ch3_current), Icons.Rounded.Power))
}
}
}

View file

@ -37,15 +37,20 @@ constructor(
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(scope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(scope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.longName)
nodeRequestActions.requestUserInfo(scope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestNeighborInfo ->
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.longName)
nodeRequestActions.requestNeighborInfo(scope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestPosition ->
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.longName)
nodeRequestActions.requestPosition(scope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestTelemetry ->
nodeRequestActions.requestTelemetry(scope, action.node.num, action.node.user.longName, action.type)
nodeRequestActions.requestTelemetry(
scope,
action.node.num,
action.node.user.long_name ?: "",
action.type,
)
is NodeMenuAction.TraceRoute ->
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.longName)
nodeRequestActions.requestTraceroute(scope, action.node.num, action.node.user.long_name ?: "")
else -> {}
}
}

View file

@ -143,7 +143,7 @@ private fun NodeDetailScaffold(
modifier = modifier,
topBar = {
MainAppBar(
title = node?.user?.longName ?: "",
title = node?.user?.long_name ?: "",
ourNode = uiState.ourNode,
showNodeChip = false,
canNavigateUp = true,

View file

@ -54,13 +54,15 @@ import org.meshtastic.core.strings.fallback_node_name
import org.meshtastic.core.ui.util.toPosition
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.metrics.EnvironmentMetricsState
import org.meshtastic.feature.node.metrics.safeNumber
import org.meshtastic.feature.node.model.LogsType
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.Config
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
data class NodeDetailUiState(
@ -110,35 +112,43 @@ constructor(
val telemetryFlow = meshLogRepository.getTelemetryFrom(nodeId).distinctUntilChanged()
val packetsFlow = meshLogRepository.getMeshPacketsFrom(nodeId).distinctUntilChanged()
val posPacketsFlow =
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP_VALUE).distinctUntilChanged()
meshLogRepository.getMeshPacketsFrom(nodeId, PortNum.POSITION_APP.value).distinctUntilChanged()
val paxLogsFlow =
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP_VALUE).distinctUntilChanged()
meshLogRepository.getLogsFrom(nodeId, PortNum.PAXCOUNTER_APP.value).distinctUntilChanged()
val trReqsFlow =
meshLogRepository
.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE)
.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value)
.map { logs ->
logs.filter { log ->
with(log.fromRadio.packet) {
hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId
}
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()
meshLogRepository.getLogsFrom(nodeId, PortNum.TRACEROUTE_APP.value).distinctUntilChanged()
val niReqsFlow =
meshLogRepository
.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE)
.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value)
.map { logs ->
logs.filter { log ->
with(log.fromRadio.packet) {
hasDecoded() && decoded.wantResponse && from == 0 && to == nodeId
}
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()
meshLogRepository.getLogsFrom(nodeId, PortNum.NEIGHBORINFO_APP.value).distinctUntilChanged()
combine(
nodeRepository.ourNodeInfo,
@ -154,7 +164,7 @@ constructor(
trResFlow,
niReqsFlow,
niResFlow,
meshLogRepository.getMyNodeInfo().map { it?.firmwareEdition }.distinctUntilChanged(),
meshLogRepository.getMyNodeInfo().map { it?.firmware_edition }.distinctUntilChanged(),
firmwareReleaseRepository.stableRelease,
firmwareReleaseRepository.alphaRelease,
nodeRequestActions.lastTracerouteTimes,
@ -167,16 +177,16 @@ constructor(
ourNode = args[0] as Node?,
ourNodeNum = args[1] as Int?,
myInfo = (args[3] as MyNodeEntity?)?.toMyNodeInfo(),
profile = args[4] as org.meshtastic.proto.ClientOnlyProtos.DeviceProfile,
profile = args[4] as org.meshtastic.proto.DeviceProfile,
telemetry = args[5] as List<Telemetry>,
packets = args[6] as List<MeshProtos.MeshPacket>,
positionPackets = args[7] as List<MeshProtos.MeshPacket>,
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>,
firmwareEdition = args[13] as MeshProtos.FirmwareEdition?,
firmwareEditionArg = args[13] as? FirmwareEdition,
stable = args[14] as FirmwareRelease?,
alpha = args[15] as FirmwareRelease?,
lastTracerouteTime = (args[16] as Map<Int, Long>)[nodeId],
@ -185,12 +195,12 @@ constructor(
}
.flatMapLatest { data ->
val pioEnv = if (data.nodeId == data.ourNodeNum) data.myInfo?.pioEnv else null
val hwModel = data.actualNode.user.hwModel.safeNumber()
val hwModel = data.actualNode.user.hw_model?.value ?: 0
flow {
val hw = deviceHardwareRepository.getDeviceHardwareByModel(hwModel, pioEnv).getOrNull()
val moduleConfig = data.profile.moduleConfig
val displayUnits = data.profile.config.display.units
val moduleConfig = data.profile.module_config
val displayUnits = data.profile.config?.display?.units
val metricsState =
MetricsState(
@ -198,22 +208,22 @@ constructor(
isLocal = data.nodeId == data.ourNodeNum,
deviceHardware = hw,
reportedTarget = pioEnv,
isManaged = data.profile.config.security.isManaged,
isManaged = data.profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig.telemetry.environmentDisplayFahrenheit ||
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
deviceMetrics = data.telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = data.telemetry.filter { it.hasPowerMetrics() },
hostMetrics = data.telemetry.filter { it.hasHostMetrics() },
signalMetrics = data.packets.filter { it.rxTime > 0 },
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 { (it.rx_time ?: 0) > 0 },
positionLogs = data.positionPackets.mapNotNull { it.toPosition() },
paxMetrics = data.paxLogs,
tracerouteRequests = data.tracerouteRequests,
tracerouteResults = data.tracerouteResults,
neighborInfoRequests = data.neighborInfoRequests,
neighborInfoResults = data.neighborInfoResults,
firmwareEdition = data.firmwareEdition,
firmwareEdition = data.firmwareEditionArg,
latestStableFirmware = data.stable ?: FirmwareRelease(),
latestAlphaFirmware = data.alpha ?: FirmwareRelease(),
)
@ -222,10 +232,11 @@ constructor(
EnvironmentMetricsState(
environmentMetrics =
data.telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
val em = it.environment_metrics
em != null &&
em.relative_humidity != null &&
em.temperature != null &&
em.temperature!!.isNaN().not()
},
)
@ -275,20 +286,24 @@ constructor(
is NodeMenuAction.Mute -> nodeManagementActions.muteNode(viewModelScope, action.node)
is NodeMenuAction.Favorite -> nodeManagementActions.favoriteNode(viewModelScope, action.node)
is NodeMenuAction.RequestUserInfo ->
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.longName)
nodeRequestActions.requestUserInfo(viewModelScope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestNeighborInfo ->
nodeRequestActions.requestNeighborInfo(viewModelScope, action.node.num, action.node.user.longName)
nodeRequestActions.requestNeighborInfo(
viewModelScope,
action.node.num,
action.node.user.long_name ?: "",
)
is NodeMenuAction.RequestPosition ->
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.longName)
nodeRequestActions.requestPosition(viewModelScope, action.node.num, action.node.user.long_name ?: "")
is NodeMenuAction.RequestTelemetry ->
nodeRequestActions.requestTelemetry(
viewModelScope,
action.node.num,
action.node.user.longName,
action.node.user.long_name ?: "",
action.type,
)
is NodeMenuAction.TraceRoute ->
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.longName)
nodeRequestActions.requestTraceroute(viewModelScope, action.node.num, action.node.user.long_name ?: "")
else -> {}
}
}
@ -306,17 +321,12 @@ constructor(
}
@Suppress("MagicNumber")
private fun createFallbackNode(nodeNum: Int): Node {
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 =
MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(longName)
.setShortName(safeUserId)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET)
return Node(num = nodeNum, user = defaultUser)
}
}
@ -327,16 +337,16 @@ private data class NodeDetailUiStateData(
val ourNode: Node?,
val ourNodeNum: Int?,
val myInfo: MyNodeInfo?,
val profile: org.meshtastic.proto.ClientOnlyProtos.DeviceProfile,
val profile: org.meshtastic.proto.DeviceProfile,
val telemetry: List<Telemetry>,
val packets: List<MeshProtos.MeshPacket>,
val positionPackets: List<MeshProtos.MeshPacket>,
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 firmwareEdition: MeshProtos.FirmwareEdition?,
val firmwareEditionArg: FirmwareEdition?,
val stable: FirmwareRelease?,
val alpha: FirmwareRelease?,
val lastTracerouteTime: Long?,

View file

@ -83,7 +83,7 @@ import org.meshtastic.core.ui.theme.StatusColors.StatusRed
import org.meshtastic.feature.node.component.NodeActionDialogs
import org.meshtastic.feature.node.component.NodeFilterTextField
import org.meshtastic.feature.node.component.NodeItem
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.SharedContact
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "CyclomaticComplexMethod")
@ -134,8 +134,7 @@ fun NodeListScreen(
},
floatingActionButton = {
val shareCapable = ourNode?.capabilities?.supportsQrCodeSharing ?: false
val sharedContact: AdminProtos.SharedContact? by
viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
val sharedContact: SharedContact? by viewModel.sharedContactRequested.collectAsStateWithLifecycle(null)
AddContactFAB(
sharedContact = sharedContact,
modifier =

View file

@ -35,8 +35,8 @@ import org.meshtastic.core.database.model.NodeSortOption
import org.meshtastic.core.service.ServiceRepository
import org.meshtastic.core.ui.viewmodel.stateInWhileSubscribed
import org.meshtastic.feature.node.model.isEffectivelyUnmessageable
import org.meshtastic.proto.AdminProtos
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.Config
import org.meshtastic.proto.SharedContact
import javax.inject.Inject
@HiltViewModel
@ -59,7 +59,7 @@ constructor(
val connectionState = serviceRepository.connectionState
private val _sharedContactRequested: MutableStateFlow<AdminProtos.SharedContact?> = MutableStateFlow(null)
private val _sharedContactRequested: MutableStateFlow<SharedContact?> = MutableStateFlow(null)
val sharedContactRequested = _sharedContactRequested.asStateFlow()
private val nodeSortOption = nodeFilterPreferences.nodeSortOption
@ -99,8 +99,8 @@ constructor(
NodesUiState(
sort = sort,
filter = nodeFilter,
distanceUnits = profile.config.display.units.number,
tempInFahrenheit = profile.moduleConfig.telemetry.environmentDisplayFahrenheit,
distanceUnits = profile.config?.display?.units?.value ?: 0,
tempInFahrenheit = profile.module_config?.telemetry?.environment_display_fahrenheit ?: false,
)
}
.stateInWhileSubscribed(initialValue = NodesUiState())
@ -124,10 +124,10 @@ constructor(
val role = node.user.role
val infrastructureRoles =
listOf(
ConfigProtos.Config.DeviceConfig.Role.ROUTER,
ConfigProtos.Config.DeviceConfig.Role.REPEATER,
ConfigProtos.Config.DeviceConfig.Role.ROUTER_LATE,
ConfigProtos.Config.DeviceConfig.Role.CLIENT_BASE,
Config.DeviceConfig.Role.ROUTER,
Config.DeviceConfig.Role.REPEATER,
Config.DeviceConfig.Role.ROUTER_LATE,
Config.DeviceConfig.Role.CLIENT_BASE,
)
role !in infrastructureRoles && !node.isEffectivelyUnmessageable
} else {
@ -151,7 +151,7 @@ constructor(
nodeFilterPreferences.setNodeSort(sort)
}
fun setSharedContactRequested(sharedContact: AdminProtos.SharedContact?) {
fun setSharedContactRequested(sharedContact: SharedContact?) {
_sharedContactRequested.value = sharedContact
}

View file

@ -180,7 +180,7 @@ fun <T> BaseMetricScreen(
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(titleRes) + " (${data.size} ${stringResource(Res.string.logs)})",
ourNode = null,
showNodeChip = false,

View file

@ -81,21 +81,20 @@ import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.Telemetry
private enum class Device(val color: Color) {
BATTERY(Green) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
override fun getValue(telemetry: Telemetry): Float = (telemetry.device_metrics?.battery_level ?: 0).toFloat()
},
VOLTAGE(Gold) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.voltage
override fun getValue(telemetry: Telemetry): Float = telemetry.device_metrics?.voltage ?: 0f
},
CH_UTIL(Purple) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization
override fun getValue(telemetry: Telemetry): Float = telemetry.device_metrics?.channel_utilization ?: 0f
},
AIR_UTIL(Cyan) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.airUtilTx
override fun getValue(telemetry: Telemetry): Float = telemetry.device_metrics?.air_util_tx ?: 0f
}, ;
abstract fun getValue(telemetry: Telemetry): Float
@ -125,10 +124,10 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
val state by viewModel.state.collectAsStateWithLifecycle()
val data = state.deviceMetrics
val hasBattery = remember(data) { data.any { it.deviceMetrics.hasBatteryLevel() } }
val hasVoltage = remember(data) { data.any { it.deviceMetrics.hasVoltage() } }
val hasChUtil = remember(data) { data.any { it.deviceMetrics.hasChannelUtilization() } }
val hasAirUtil = remember(data) { data.any { it.deviceMetrics.hasAirUtilTx() } }
val hasBattery = remember(data) { data.any { it.device_metrics?.battery_level != null } }
val hasVoltage = remember(data) { data.any { it.device_metrics?.voltage != null } }
val hasChUtil = remember(data) { data.any { it.device_metrics?.channel_utilization != null } }
val hasAirUtil = remember(data) { data.any { it.device_metrics?.air_util_tx != null } }
val filteredLegendData =
remember(hasBattery, hasVoltage, hasChUtil, hasAirUtil) {
@ -173,7 +172,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
telemetryType = TelemetryType.DEVICE,
titleRes = Res.string.device_metrics_log,
data = data,
timeProvider = { it.time.toDouble() },
timeProvider = { (it.time ?: 0).toDouble() },
infoData = infoItems,
chartPart = { modifier, selectedX, vicoScrollState, onPointSelected ->
DeviceMetricsChart(
@ -190,8 +189,8 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
itemsIndexed(data) { _, telemetry ->
DeviceMetricsCard(
telemetry = telemetry,
isSelected = telemetry.time.toDouble() == selectedX,
onClick = { onCardClick(telemetry.time.toDouble()) },
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((telemetry.time ?: 0).toDouble()) },
)
}
}
@ -235,16 +234,28 @@ private fun DeviceMetricsChart(
modelProducer.runTransaction {
/* Series for Left Axis (0-100%) */
lineSeries {
series(x = telemetries.map { it.time }, y = telemetries.map { it.deviceMetrics.batteryLevel })
val chUtilData = telemetries.filter { !it.deviceMetrics.channelUtilization.isNaN() }
series(x = chUtilData.map { it.time }, y = chUtilData.map { it.deviceMetrics.channelUtilization })
val airUtilData = telemetries.filter { !it.deviceMetrics.airUtilTx.isNaN() }
series(x = airUtilData.map { it.time }, y = airUtilData.map { it.deviceMetrics.airUtilTx })
series(
x = telemetries.map { it.time ?: 0 },
y = telemetries.map { it.device_metrics?.battery_level ?: 0 },
)
val chUtilData = telemetries.filter { it.device_metrics?.channel_utilization != null }
series(
x = chUtilData.map { it.time ?: 0 },
y = chUtilData.map { it.device_metrics?.channel_utilization ?: 0f },
)
val airUtilData = telemetries.filter { it.device_metrics?.air_util_tx != null }
series(
x = airUtilData.map { it.time ?: 0 },
y = airUtilData.map { it.device_metrics?.air_util_tx ?: 0f },
)
}
/* Series for Right Axis (Voltage) */
lineSeries {
val voltageData = telemetries.filter { !it.deviceMetrics.voltage.isNaN() }
series(x = voltageData.map { it.time }, y = voltageData.map { it.deviceMetrics.voltage })
val voltageData = telemetries.filter { it.device_metrics?.voltage != null }
series(
x = voltageData.map { it.time ?: 0 },
y = voltageData.map { it.device_metrics?.voltage ?: 0f },
)
}
}
}
@ -317,17 +328,17 @@ private fun DeviceMetricsChartPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetries =
List(20) { i ->
Telemetry.newBuilder()
.setTime(now - (19 - i) * 60 * 60) // 1-hour intervals, oldest first
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(80 - i)
.setVoltage(3.7f - i * 0.02f)
.setChannelUtilization(10f + i * 2)
.setAirUtilTx(5f + i)
.setUptimeSeconds(3600 + i * 300),
)
.build()
Telemetry(
time = now - (19 - i) * 60 * 60, // 1-hour intervals, oldest first
device_metrics =
org.meshtastic.proto.DeviceMetrics(
battery_level = 80 - i,
voltage = 3.7f - i * 0.02f,
channel_utilization = 10f + i * 2,
air_util_tx = 5f + i,
uptime_seconds = 3600 + i * 300,
),
)
}
AppTheme {
DeviceMetricsChart(
@ -345,8 +356,8 @@ private fun DeviceMetricsChartPreview() {
@Composable
@Suppress("LongMethod")
private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val deviceMetrics = telemetry.deviceMetrics
val time = telemetry.time * MS_PER_SEC
val deviceMetrics = telemetry.device_metrics
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -371,15 +382,18 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
)
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics.hasBatteryLevel()) {
if (deviceMetrics?.battery_level != null) {
MetricIndicator(Device.BATTERY.color)
Spacer(Modifier.width(4.dp))
}
if (deviceMetrics.hasVoltage()) {
if (deviceMetrics?.voltage != null) {
MetricIndicator(Device.VOLTAGE.color)
Spacer(Modifier.width(8.dp))
}
MaterialBatteryInfo(level = deviceMetrics.batteryLevel, voltage = deviceMetrics.voltage)
MaterialBatteryInfo(
level = deviceMetrics?.battery_level ?: 0,
voltage = deviceMetrics?.voltage ?: 0f,
)
}
}
@ -388,28 +402,31 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
/* Channel Utilization and Air Utilization Tx */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (deviceMetrics.hasChannelUtilization()) {
if (deviceMetrics?.channel_utilization != null) {
MetricIndicator(Device.CH_UTIL.color)
Spacer(Modifier.width(4.dp))
Text(
text = "Ch: %.1f%%".format(deviceMetrics.channelUtilization),
text = "Ch: %.1f%%".format(deviceMetrics.channel_utilization ?: 0f),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Spacer(Modifier.width(12.dp))
}
if (deviceMetrics.hasAirUtilTx()) {
if (deviceMetrics?.air_util_tx != null) {
MetricIndicator(Device.AIR_UTIL.color)
Spacer(Modifier.width(4.dp))
Text(
text = "Air: %.1f%%".format(deviceMetrics.airUtilTx),
text = "Air: %.1f%%".format(deviceMetrics.air_util_tx ?: 0f),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
Text(
text = stringResource(Res.string.uptime) + ": " + formatUptime(deviceMetrics.uptimeSeconds),
text =
stringResource(Res.string.uptime) +
": " +
formatUptime(deviceMetrics?.uptime_seconds ?: 0),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
@ -426,17 +443,17 @@ private fun DeviceMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick
private fun DeviceMetricsCardPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetry =
Telemetry.newBuilder()
.setTime(now)
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(75)
.setVoltage(3.65f)
.setChannelUtilization(22.5f)
.setAirUtilTx(12.0f)
.setUptimeSeconds(7200),
)
.build()
Telemetry(
time = now,
device_metrics =
org.meshtastic.proto.DeviceMetrics(
battery_level = 75,
voltage = 3.65f,
channel_utilization = 22.5f,
air_util_tx = 12.0f,
uptime_seconds = 7200,
),
)
AppTheme { DeviceMetricsCard(telemetry = telemetry, isSelected = false, onClick = {}) }
}
@ -447,17 +464,17 @@ private fun DeviceMetricsScreenPreview() {
val now = (System.currentTimeMillis() / 1000).toInt()
val telemetries =
List(24) { i ->
Telemetry.newBuilder()
.setTime(now - (23 - i) * 60 * 60) // 1-hour intervals, oldest first
.setDeviceMetrics(
TelemetryProtos.DeviceMetrics.newBuilder()
.setBatteryLevel(85 - i * 2) // Battery decreases over time
.setVoltage(3.8f - i * 0.01f) // Voltage decreases slightly
.setChannelUtilization(15f + i * 1.5f) // Channel utilization increases
.setAirUtilTx(8f + i * 0.8f) // Air utilization increases
.setUptimeSeconds(3600 + i * 3600), // Uptime increases by 1 hour each
)
.build()
Telemetry(
time = now - (23 - i) * 60 * 60, // 1-hour intervals, oldest first
device_metrics =
org.meshtastic.proto.DeviceMetrics(
battery_level = 85 - i * 2, // Battery decreases over time
voltage = 3.8f - i * 0.01f, // Voltage decreases slightly
channel_utilization = 15f + i * 1.5f, // Channel utilization increases
air_util_tx = 8f + i * 0.8f, // Air utilization increases
uptime_seconds = 3600 + i * 3600, // Uptime increases by 1 hour each
),
)
}
AppTheme {

View file

@ -42,7 +42,7 @@ import org.meshtastic.core.strings.soil_moisture
import org.meshtastic.core.strings.soil_temperature
import org.meshtastic.core.strings.temperature
import org.meshtastic.core.strings.uv_lux
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.Telemetry
@Suppress("MagicNumber")
private val LEGEND_DATA_1 =
@ -137,7 +137,7 @@ fun EnvironmentMetricsChart(
val pressureData =
telemetries.filter {
val v = Environment.BAROMETRIC_PRESSURE.getValue(it)
v != null && !v.isNaN()
it.time != 0 && v != null && !v.isNaN()
}
series(
x = pressureData.map { it.time },
@ -152,7 +152,7 @@ fun EnvironmentMetricsChart(
val metricData =
telemetries.filter {
val v = metric.getValue(it)
v != null && !v.isNaN()
it.time != 0 && v != null && !v.isNaN()
}
series(x = metricData.map { it.time }, y = metricData.map { metric.getValue(it)!! })
}

View file

@ -90,9 +90,7 @@ import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.feature.node.metrics.CommonCharts.SCROLL_BIAS
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.copy
import org.meshtastic.proto.Telemetry
@Suppress("LongMethod")
@Composable
@ -122,15 +120,13 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
val processedTelemetries: List<Telemetry> =
if (state.isFahrenheit) {
data.map { telemetry ->
val temperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.temperature)
val soilTemperatureFahrenheit = celsiusToFahrenheit(telemetry.environmentMetrics.soilTemperature)
telemetry.copy {
environmentMetrics =
telemetry.environmentMetrics.copy {
temperature = temperatureFahrenheit
soilTemperature = soilTemperatureFahrenheit
}
}
val em = telemetry.environment_metrics ?: return@map telemetry
val temperatureFahrenheit = em.temperature?.let { celsiusToFahrenheit(it) }
val soilTemperatureFahrenheit = em.soil_temperature?.let { celsiusToFahrenheit(it) }
telemetry.copy(
environment_metrics =
em.copy(temperature = temperatureFahrenheit, soil_temperature = soilTemperatureFahrenheit),
)
}
} else {
data
@ -141,7 +137,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.env_metrics_log) +
" (${processedTelemetries.size} ${stringResource(Res.string.logs)})",
@ -185,7 +181,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = processedTelemetries.indexOfFirst { it.time.toDouble() == x }
val index = processedTelemetries.indexOfFirst { (it.time ?: 0).toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
@ -198,12 +194,12 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
EnvironmentMetricsCard(
telemetry = telemetry,
environmentDisplayFahrenheit = state.isFahrenheit,
isSelected = telemetry.time.toDouble() == selectedX,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = {
selectedX = telemetry.time.toDouble()
selectedX = (telemetry.time ?: 0).toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x(telemetry.time.toDouble(), SCROLL_BIAS),
Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), SCROLL_BIAS),
)
}
},
@ -217,7 +213,10 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNa
}
@Composable
private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
private fun TemperatureDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
) {
envMetrics.temperature?.let { temperature ->
if (!temperature.isNaN()) {
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
@ -235,9 +234,9 @@ private fun TemperatureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e
}
@Composable
private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val hasHumidity = envMetrics.relativeHumidity?.let { !it.isNaN() } == true
val hasPressure = envMetrics.barometricPressure?.let { !it.isNaN() && it > 0 } == true
private fun HumidityAndBarometricPressureDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasHumidity = envMetrics.relative_humidity?.let { !it.isNaN() } == true
val hasPressure = envMetrics.barometric_pressure?.let { !it.isNaN() && it > 0 } == true
if (hasHumidity || hasPressure) {
Row(
@ -245,7 +244,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env
horizontalArrangement = Arrangement.SpaceBetween,
) {
if (hasHumidity) {
val humidity = envMetrics.relativeHumidity!!
val humidity = envMetrics.relative_humidity!!
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(Environment.HUMIDITY.color)
Spacer(Modifier.width(4.dp))
@ -258,7 +257,7 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env
}
}
if (hasPressure) {
val pressure = envMetrics.barometricPressure!!
val pressure = envMetrics.barometric_pressure!!
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(Environment.BAROMETRIC_PRESSURE.color)
Spacer(Modifier.width(4.dp))
@ -275,15 +274,18 @@ private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.Env
}
@Composable
private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
private fun SoilMetricsDisplay(
envMetrics: org.meshtastic.proto.EnvironmentMetrics,
environmentDisplayFahrenheit: Boolean,
) {
if (
envMetrics.soilTemperature != null ||
(envMetrics.soilMoisture != null && envMetrics.soilMoisture != Int.MIN_VALUE)
envMetrics.soil_temperature != null ||
(envMetrics.soil_moisture != null && envMetrics.soil_moisture != Int.MIN_VALUE)
) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val soilTemperatureTextFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
val soilMoistureTextFormat = "%s %d%%"
envMetrics.soilMoisture?.let { soilMoistureValue ->
envMetrics.soil_moisture?.let { soilMoistureValue ->
if (soilMoistureValue != Int.MIN_VALUE) {
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(Environment.SOIL_MOISTURE.color)
@ -300,7 +302,7 @@ private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e
}
}
}
envMetrics.soilTemperature?.let { soilTemperature ->
envMetrics.soil_temperature?.let { soilTemperature ->
if (!soilTemperature.isNaN()) {
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(Environment.SOIL_TEMPERATURE.color)
@ -322,9 +324,9 @@ private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, e
}
@Composable
private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val hasLux = envMetrics.lux != null && !envMetrics.lux.isNaN()
val hasUvLux = envMetrics.uvLux != null && !envMetrics.uvLux.isNaN()
private fun LuxUVLuxDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasLux = envMetrics.lux != null && !envMetrics.lux!!.isNaN()
val hasUvLux = envMetrics.uv_lux != null && !envMetrics.uv_lux!!.isNaN()
if (hasLux || hasUvLux) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -341,7 +343,7 @@ private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
}
}
if (hasUvLux) {
val uvLuxValue = envMetrics.uvLux!!
val uvLuxValue = envMetrics.uv_lux!!
Row(verticalAlignment = Alignment.CenterVertically) {
MetricIndicator(Environment.UV_LUX.color)
Spacer(Modifier.width(4.dp))
@ -357,9 +359,9 @@ private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
}
@Composable
private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage.isNaN()
val hasCurrent = envMetrics.current != null && !envMetrics.current.isNaN()
private fun VoltageCurrentDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val hasVoltage = envMetrics.voltage != null && !envMetrics.voltage!!.isNaN()
val hasCurrent = envMetrics.current != null && !envMetrics.current!!.isNaN()
if (hasVoltage || hasCurrent) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -384,9 +386,9 @@ private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics
}
@Composable
private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
private fun GasCompositionDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
val iaqValue = envMetrics.iaq
val gasResistance = envMetrics.gasResistance
val gasResistance = envMetrics.gas_resistance
if ((iaqValue != null && iaqValue != Int.MIN_VALUE) || (gasResistance?.isFinite() == true)) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -419,7 +421,7 @@ private fun GasCompositionDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics
}
@Composable
private fun RadiationDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
private fun RadiationDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) {
envMetrics.radiation?.let { radiation ->
if (!radiation.isNaN() && radiation > 0f) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -441,8 +443,8 @@ private fun EnvironmentMetricsCard(
isSelected: Boolean,
onClick: () -> Unit,
) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -465,8 +467,8 @@ private fun EnvironmentMetricsCard(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
val envMetrics = telemetry.environment_metrics ?: org.meshtastic.proto.EnvironmentMetrics()
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
@ -493,27 +495,23 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
@Preview(showBackground = true)
@Composable
private fun PreviewEnvironmentMetricsContent() {
// Build a fake EnvironmentMetrics using the generated proto builder APIs
val fakeEnvMetrics =
TelemetryProtos.EnvironmentMetrics.newBuilder()
.setTemperature(22.5f)
.setRelativeHumidity(55.0f)
.setBarometricPressure(1013.25f)
.setSoilMoisture(33)
.setSoilTemperature(18.0f)
.setLux(100.0f)
.setUvLux(100.0f)
.setVoltage(3.7f)
.setCurrent(0.12f)
.setIaq(100)
.setRadiation(0.15f)
.setGasResistance(1200.0f)
.build()
org.meshtastic.proto.EnvironmentMetrics(
temperature = 22.5f,
relative_humidity = 55.0f,
barometric_pressure = 1013.25f,
soil_moisture = 33,
soil_temperature = 18.0f,
lux = 100.0f,
uv_lux = 100.0f,
voltage = 3.7f,
current = 0.12f,
iaq = 100,
radiation = 0.15f,
gas_resistance = 1200.0f,
)
val fakeTelemetry =
TelemetryProtos.Telemetry.newBuilder()
.setTime((System.currentTimeMillis() / 1000).toInt())
.setEnvironmentMetrics(fakeEnvMetrics)
.build()
Telemetry(time = (System.currentTimeMillis() / 1000).toInt(), environment_metrics = fakeEnvMetrics)
MaterialTheme {
Surface { EnvironmentMetricsContent(telemetry = fakeTelemetry, environmentDisplayFahrenheit = false) }
}

View file

@ -27,58 +27,57 @@ import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.Telemetry
@Suppress("MagicNumber")
enum class Environment(val color: Color) {
TEMPERATURE(Red) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.temperature
},
HUMIDITY(Blue) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.relative_humidity
},
SOIL_TEMPERATURE(Pink) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.soilTemperature
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_temperature
},
SOIL_MOISTURE(Purple) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
telemetry.environmentMetrics.soilMoisture.toFloat()
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.soil_moisture?.toFloat()
},
BAROMETRIC_PRESSURE(Green) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.barometric_pressure
},
GAS_RESISTANCE(InfantryBlue) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.gas_resistance
},
IAQ(Cyan) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq.toFloat()
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.iaq?.toFloat()
},
LUX(Gold) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.lux
},
UV_LUX(Orange) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.uvLux
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.uv_lux
}, ;
abstract fun getValue(telemetry: TelemetryProtos.Telemetry): Float?
abstract fun getValue(telemetry: Telemetry): Float?
}
/**
* @param metrics the [List] of [TelemetryProtos.Telemetry]
* @param metrics the [List] of [Telemetry]
* @param shouldPlot a [List] the size of [Environment] used to determine if a metric should be plotted
* @param leftMinMax [Pair] with the min and max of the barometric pressure
* @param rightMinMax [Pair] with the combined min and max of: the temperature, humidity, and IAQ
* @param times [Pair] with the oldest and newest times in that order
*/
data class EnvironmentGraphingData(
val metrics: List<TelemetryProtos.Telemetry>,
val metrics: List<Telemetry>,
val shouldPlot: List<Boolean>,
val leftMinMax: Pair<Float, Float> = Pair(0f, 0f),
val rightMinMax: Pair<Float, Float> = Pair(0f, 0f),
val times: Pair<Int, Int> = Pair(0, 0),
)
data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.Telemetry> = emptyList()) {
data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emptyList()) {
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
/**
@ -99,7 +98,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
val maxValues = mutableListOf<Float>()
// Temperature
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature.takeIf { !it.isNaN() } }
val temperatures = telemetries.mapNotNull { it.environment_metrics?.temperature?.takeIf { !it.isNaN() } }
if (temperatures.isNotEmpty()) {
var minTempValue = temperatures.minOf { it }
var maxTempValue = temperatures.maxOf { it }
@ -114,7 +113,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
// Relative Humidity
val humidities =
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity.takeIf { !it.isNaN() && it != 0.0f } }
telemetries.mapNotNull { it.environment_metrics?.relative_humidity?.takeIf { !it.isNaN() && it != 0.0f } }
if (humidities.isNotEmpty()) {
minValues.add(humidities.minOf { it })
maxValues.add(humidities.maxOf { it })
@ -122,7 +121,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Soil Temperature
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature.takeIf { !it.isNaN() } }
val soilTemperatures =
telemetries.mapNotNull { it.environment_metrics?.soil_temperature?.takeIf { !it.isNaN() } }
if (soilTemperatures.isNotEmpty()) {
var minSoilTemperatureValue = soilTemperatures.minOf { it }
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
@ -136,7 +136,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Soil Moisture
val soilMoistures = telemetries.mapNotNull { it.environmentMetrics.soilMoisture.takeIf { it != Int.MIN_VALUE } }
val soilMoistures = telemetries.mapNotNull { it.environment_metrics?.soil_moisture?.takeIf { it != 0 } }
if (soilMoistures.isNotEmpty()) {
minValues.add(soilMoistures.minOf { it.toFloat() })
maxValues.add(soilMoistures.maxOf { it.toFloat() })
@ -144,7 +144,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// IAQ
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq.takeIf { it != Int.MIN_VALUE } }
val iaqs = telemetries.mapNotNull { it.environment_metrics?.iaq?.takeIf { it != 0 } }
if (iaqs.isNotEmpty()) {
minValues.add(iaqs.minOf { it.toFloat() })
maxValues.add(iaqs.maxOf { it.toFloat() })
@ -152,7 +152,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Barometric Pressure
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure.takeIf { !it.isNaN() } }
val pressures = telemetries.mapNotNull { it.environment_metrics?.barometric_pressure?.takeIf { !it.isNaN() } }
var minPressureValue = 0f
var maxPressureValue = 0f
if (pressures.isNotEmpty()) {
@ -162,7 +162,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// Lux
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux.takeIf { !it.isNaN() } }
val luxValues = telemetries.mapNotNull { it.environment_metrics?.lux?.takeIf { !it.isNaN() } }
if (luxValues.isNotEmpty()) {
minValues.add(luxValues.minOf { it })
maxValues.add(luxValues.maxOf { it })
@ -170,7 +170,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
}
// UVLux
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux.takeIf { !it.isNaN() } }
val uvLuxValues = telemetries.mapNotNull { it.environment_metrics?.uv_lux?.takeIf { !it.isNaN() } }
if (uvLuxValues.isNotEmpty()) {
minValues.add(uvLuxValues.minOf { it })
maxValues.add(uvLuxValues.maxOf { it })
@ -180,7 +180,8 @@ data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
val (oldest, newest) = Pair(telemetries.minBy { it.time }, telemetries.maxBy { it.time })
val oldest = telemetries.minBy { it.time }
val newest = telemetries.maxBy { it.time }
return EnvironmentGraphingData(
metrics = telemetries,

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,11 +14,10 @@
* 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.feature.node.metrics
import co.touchlab.kermit.Logger
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.HardwareModel
/**
* Safely extracts the hardware model number from a HardwareModel enum.
@ -31,8 +30,8 @@ import org.meshtastic.proto.MeshProtos
* @return The hardware model number, or the fallback value if the enum is unknown
*/
@Suppress("detekt:SwallowedException")
fun MeshProtos.HardwareModel.safeNumber(fallbackValue: Int = -1): Int = try {
this.number
fun HardwareModel.safeNumber(fallbackValue: Int = -1): Int = try {
this.value
} catch (e: IllegalArgumentException) {
Logger.w { "Unknown hardware model enum value: $this, using fallback value: $fallbackValue" }
fallbackValue

View file

@ -72,7 +72,8 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.HostMetrics
import org.meshtastic.proto.Telemetry
import java.text.DecimalFormat
@OptIn(ExperimentalFoundationApi::class)
@ -97,7 +98,7 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -126,8 +127,8 @@ fun HostMetricsLogScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), o
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod", "MagicNumber")
@Composable
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Telemetry) {
val hostMetrics = telemetry.hostMetrics
fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: Telemetry) {
val hostMetrics = telemetry.host_metrics
val time = telemetry.time * CommonCharts.MS_PER_SEC
Card(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp).combinedClickable(onClick = { /* Handle click */ }),
@ -144,74 +145,86 @@ fun HostMetricsItem(modifier: Modifier = Modifier, telemetry: TelemetryProtos.Te
text = DATE_TIME_FORMAT.format(time),
style = MaterialTheme.typography.titleMediumEmphasized,
)
LogLine(
label = stringResource(Res.string.uptime),
value = formatUptime(hostMetrics.uptimeSeconds),
modifier = Modifier.fillMaxWidth(),
)
LogLine(
label = stringResource(Res.string.free_memory),
value = formatBytes(hostMetrics.freememBytes),
modifier = Modifier.fillMaxWidth(),
)
LogLine(
label = stringResource(Res.string.disk_free_indexed, 1),
value = formatBytes(hostMetrics.diskfree1Bytes),
modifier = Modifier.fillMaxWidth(),
)
if (hostMetrics.hasDiskfree2Bytes()) {
hostMetrics?.uptime_seconds?.let {
LogLine(
label = stringResource(Res.string.uptime),
value = formatUptime(it),
modifier = Modifier.fillMaxWidth(),
)
}
hostMetrics?.freemem_bytes?.let {
LogLine(
label = stringResource(Res.string.free_memory),
value = formatBytes(it),
modifier = Modifier.fillMaxWidth(),
)
}
hostMetrics?.diskfree1_bytes?.let {
LogLine(
label = stringResource(Res.string.disk_free_indexed, 1),
value = formatBytes(it),
modifier = Modifier.fillMaxWidth(),
)
}
hostMetrics?.diskfree2_bytes?.let {
LogLine(
label = stringResource(Res.string.disk_free_indexed, 2),
value = formatBytes(hostMetrics.diskfree2Bytes),
value = formatBytes(it),
modifier = Modifier.fillMaxWidth(),
)
}
if (hostMetrics.hasDiskfree3Bytes()) {
hostMetrics?.diskfree3_bytes?.let {
LogLine(
label = stringResource(Res.string.disk_free_indexed, 3),
value = formatBytes(hostMetrics.diskfree3Bytes),
value = formatBytes(it),
modifier = Modifier.fillMaxWidth(),
)
}
LogLine(
label = stringResource(Res.string.load_indexed, 1),
value = (hostMetrics.load1 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load1 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
LogLine(
label = stringResource(Res.string.load_indexed, 5),
value = (hostMetrics.load5 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load5 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
LogLine(
label = stringResource(Res.string.load_indexed, 15),
value = (hostMetrics.load15 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load15 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
if (hostMetrics.hasUserString()) {
hostMetrics?.load1?.let {
LogLine(
label = stringResource(Res.string.load_indexed, 1),
value = (hostMetrics.load1 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load1 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
}
hostMetrics?.load5?.let {
LogLine(
label = stringResource(Res.string.load_indexed, 5),
value = (hostMetrics.load5 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load5 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
}
hostMetrics?.load15?.let {
LogLine(
label = stringResource(Res.string.load_indexed, 15),
value = (hostMetrics.load15 / 100.0).toString(),
modifier = Modifier.fillMaxWidth(),
)
LinearProgressIndicator(
progress = { hostMetrics.load15 / 10000.0f },
modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
}
hostMetrics?.user_string?.let {
Text(text = stringResource(Res.string.user_string), style = MaterialTheme.typography.bodyMedium)
Text(text = hostMetrics.userString, style = TextStyle(fontFamily = FontFamily.Monospace))
Text(text = it, style = TextStyle(fontFamily = FontFamily.Monospace))
}
}
}
@ -257,21 +270,17 @@ fun formatBytes(bytes: Long, decimalPlaces: Int = 2): String {
@Composable
private fun HostMetricsItemPreview() {
val hostMetrics =
TelemetryProtos.HostMetrics.newBuilder()
.setUptimeSeconds(3600)
.setFreememBytes(2048000)
.setDiskfree1Bytes(104857600)
.setDiskfree2Bytes(2097915200)
.setDiskfree3Bytes(44444)
.setLoad1(30)
.setLoad5(75)
.setLoad15(19)
.setUserString("test")
.build()
val logs =
TelemetryProtos.Telemetry.newBuilder()
.setTime((System.currentTimeMillis() / 1000L).toInt())
.setHostMetrics(hostMetrics)
.build()
HostMetrics(
uptime_seconds = 3600,
freemem_bytes = 2048000,
diskfree1_bytes = 104857600,
diskfree2_bytes = 2097915200,
diskfree3_bytes = 44444,
load1 = 30,
load5 = 75,
load15 = 19,
user_string = "test",
)
val logs = Telemetry(time = (System.currentTimeMillis() / 1000).toInt(), host_metrics = hostMetrics)
AppTheme { HostMetricsItem(telemetry = logs) }
}

View file

@ -62,11 +62,11 @@ import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.detail.NodeRequestActions
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.model.MetricsState
import org.meshtastic.proto.ConfigProtos.Config
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.Portnums
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.Config
import org.meshtastic.proto.HardwareModel
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.PortNum
import org.meshtastic.proto.User
import java.io.BufferedWriter
import java.io.FileNotFoundException
import java.io.FileWriter
@ -76,8 +76,9 @@ import javax.inject.Inject
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private fun MeshPacket.hasValidSignal(): Boolean =
rxTime > 0 && (rxSnr != 0f && rxRssi != 0) && (hopStart > 0 && hopStart - hopLimit == 0)
private fun MeshPacket.hasValidSignal(): Boolean = (rx_time ?: 0) > 0 &&
((rx_snr ?: 0f) != 0f && (rx_rssi ?: 0) != 0) &&
((hop_start ?: 0) > 0 && (hop_start ?: 0) - (hop_limit ?: 0) == 0)
@Suppress("LongParameterList", "TooManyFunctions")
@HiltViewModel
@ -104,10 +105,10 @@ constructor(
private val tracerouteOverlayCache = MutableStateFlow<Map<Int, TracerouteOverlay>>(emptyMap())
private fun MeshLog.hasValidTraceroute(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum }
private fun MeshLog.hasValidNeighborInfo(): Boolean =
with(fromRadio.packet) { hasDecoded() && decoded.wantResponse && from == 0 && to == destNum }
with(fromRadio.packet) { this?.decoded != null && decoded?.want_response == true && from == 0 && to == destNum }
/**
* Creates a fallback node for hidden clients or nodes not yet in the database. This prevents the detail screen from
@ -118,12 +119,7 @@ constructor(
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 =
MeshProtos.User.newBuilder()
.setId(userId)
.setLongName(longName)
.setShortName(safeUserId)
.setHwModel(MeshProtos.HardwareModel.UNSET)
.build()
User(id = userId, long_name = longName, short_name = safeUserId, hw_model = HardwareModel.UNSET)
return Node(num = nodeNum, user = defaultUser)
}
@ -189,7 +185,7 @@ constructor(
}
fun clearPosition() = viewModelScope.launch(dispatchers.io) {
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP_VALUE) }
destNum?.let { meshLogRepository.deleteLogs(it, PortNum.POSITION_APP.value) }
}
fun onServiceAction(action: ServiceAction) = viewModelScope.launch { serviceRepository.onServiceAction(action) }
@ -209,28 +205,28 @@ constructor(
nodeRequestActions.lastRequestNeighborTimes.map { it[destNum] }.stateInWhileSubscribed(null)
fun requestUserInfo() {
destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.longName ?: "") }
destNum?.let { nodeRequestActions.requestUserInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "") }
}
fun requestPosition() {
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.longName ?: "") }
destNum?.let { nodeRequestActions.requestPosition(viewModelScope, it, state.value.node?.user?.long_name ?: "") }
}
fun requestTelemetry(type: TelemetryType) {
destNum?.let {
nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.longName ?: "", type)
nodeRequestActions.requestTelemetry(viewModelScope, it, state.value.node?.user?.long_name ?: "", type)
}
}
fun requestTraceroute() {
destNum?.let {
nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.longName ?: "")
nodeRequestActions.requestTraceroute(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
fun requestNeighborInfo() {
destNum?.let {
nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.longName ?: "")
nodeRequestActions.requestNeighborInfo(viewModelScope, it, state.value.node?.user?.long_name ?: "")
}
}
@ -262,10 +258,10 @@ constructor(
// Create a fallback node if not found in database (for hidden clients, etc.)
val actualNode = node ?: createFallbackNode(currentDestNum)
val pioEnv = if (currentDestNum == ourNodeNum) myInfo?.pioEnv else null
val hwModel = actualNode.user.hw_model?.value ?: 0
val deviceHardware =
actualNode.user.hwModel.safeNumber().let {
deviceHardwareRepository.getDeviceHardwareByModel(it, target = pioEnv)
}
deviceHardwareRepository.getDeviceHardwareByModel(hwModel, target = pioEnv)
_state.update { state ->
state.copy(
node = actualNode,
@ -279,15 +275,15 @@ constructor(
launch {
radioConfigRepository.deviceProfileFlow.collect { profile ->
val moduleConfig = profile.moduleConfig
val displayUnits = profile.config.display.units
val moduleConfig = profile.module_config
val displayUnits = profile.config?.display?.units
_state.update { state ->
state.copy(
isManaged = profile.config.security.isManaged,
isManaged = profile.config?.security?.is_managed ?: false,
isFahrenheit =
moduleConfig.telemetry.environmentDisplayFahrenheit ||
moduleConfig?.telemetry?.environment_display_fahrenheit == true ||
(displayUnits == Config.DisplayConfig.DisplayUnits.IMPERIAL),
displayUnits = displayUnits,
displayUnits = displayUnits ?: Config.DisplayConfig.DisplayUnits.METRIC,
)
}
}
@ -297,19 +293,19 @@ constructor(
meshLogRepository.getTelemetryFrom(currentDestNum).collect { telemetry ->
_state.update { state ->
state.copy(
deviceMetrics = telemetry.filter { it.hasDeviceMetrics() },
powerMetrics = telemetry.filter { it.hasPowerMetrics() },
hostMetrics = telemetry.filter { it.hasHostMetrics() },
deviceMetrics = telemetry.filter { it.device_metrics != null },
powerMetrics = telemetry.filter { it.power_metrics != null },
hostMetrics = telemetry.filter { it.host_metrics != null },
)
}
_environmentState.update { state ->
state.copy(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
it.environment_metrics != null &&
it.environment_metrics?.relative_humidity != null &&
it.environment_metrics?.temperature != null &&
it.environment_metrics?.temperature?.isNaN()?.not() == true
},
)
}
@ -326,8 +322,8 @@ constructor(
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP_VALUE),
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.TRACEROUTE_APP.value),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.TRACEROUTE_APP.value),
) { request, response ->
_state.update { state ->
state.copy(
@ -341,8 +337,8 @@ constructor(
launch {
combine(
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP_VALUE),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP_VALUE),
meshLogRepository.getLogsFrom(nodeNum = 0, PortNum.NEIGHBORINFO_APP.value),
meshLogRepository.getLogsFrom(currentDestNum, PortNum.NEIGHBORINFO_APP.value),
) { request, response ->
_state.update { state ->
state.copy(
@ -357,7 +353,7 @@ constructor(
launch {
meshLogRepository.getMeshPacketsFrom(
currentDestNum,
PortNum.POSITION_APP_VALUE,
PortNum.POSITION_APP.value,
).collect { packets ->
val distinctPositions =
packets
@ -365,7 +361,7 @@ constructor(
.asFlow()
.distinctUntilChanged { old, new ->
old.time == new.time ||
(old.latitudeI == new.latitudeI && old.longitudeI == new.longitudeI)
(old.latitude_i == new.latitude_i && old.longitude_i == new.longitude_i)
}
.toList()
_state.update { state -> state.copy(positionLogs = distinctPositions) }
@ -373,10 +369,7 @@ constructor(
}
launch {
meshLogRepository.getLogsFrom(
currentDestNum,
Portnums.PortNum.PAXCOUNTER_APP_VALUE,
).collect { logs ->
meshLogRepository.getLogsFrom(currentDestNum, PortNum.PAXCOUNTER_APP.value).collect { logs ->
_state.update { state -> state.copy(paxMetrics = logs) }
}
}
@ -396,7 +389,7 @@ constructor(
launch {
meshLogRepository
.getMyNodeInfo()
.map { it?.firmwareEdition }
.map { it?.firmware_edition }
.distinctUntilChanged()
.collect { firmwareEdition ->
_state.update { state -> state.copy(firmwareEdition = firmwareEdition) }
@ -426,13 +419,13 @@ constructor(
val dateFormat = SimpleDateFormat("\"yyyy-MM-dd\",\"HH:mm:ss\"", Locale.getDefault())
positions.forEach { position ->
val rxDateTime = dateFormat.format(position.time * 1000L)
val latitude = position.latitudeI * 1e-7
val longitude = position.longitudeI * 1e-7
val rxDateTime = dateFormat.format((position.time ?: 0).toLong() * 1000L)
val latitude = (position.latitude_i ?: 0) * 1e-7
val longitude = (position.longitude_i ?: 0) * 1e-7
val altitude = position.altitude
val satsInView = position.satsInView
val speed = position.groundSpeed
val heading = "%.2f".format(position.groundTrack * 1e-5)
val satsInView = position.sats_in_view
val speed = position.ground_speed
val heading = "%.2f".format((position.ground_track ?: 0) * 1e-5)
// date,time,latitude,longitude,altitude,satsInView,speed,heading
writer.appendLine(

View file

@ -93,7 +93,8 @@ fun NeighborInfoLogScreen(
}
}
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
var showDialog by remember { mutableStateOf<AnnotatedString?>(null) }
val context = LocalContext.current
@ -115,7 +116,7 @@ fun NeighborInfoLogScreen(
topBar = {
val lastRequestNeighborsTime by viewModel.lastRequestNeighborsTime.collectAsState()
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.neighbor_info),
ourNode = null,
showNodeChip = false,
@ -142,9 +143,9 @@ fun NeighborInfoLogScreen(
) {
items(state.neighborInfoRequests, key = { it.uuid }) { log ->
val result =
remember(state.neighborInfoResults, log.fromRadio.packet.id) {
remember(state.neighborInfoResults, log.fromRadio.packet?.id) {
state.neighborInfoResults.find {
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
it.fromRadio.packet?.decoded?.request_id == log.fromRadio.packet?.id
}
}

View file

@ -85,10 +85,10 @@ import org.meshtastic.core.ui.icon.Refresh
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.PaxcountProtos
import org.meshtastic.proto.Portnums.PortNum
import org.meshtastic.proto.PortNum
import java.text.DateFormat
import java.util.Date
import org.meshtastic.proto.Paxcount as ProtoPaxcount
private enum class PaxSeries(val color: Color, val legendRes: StringResource) {
PAX(Color.Gray, Res.string.pax),
@ -137,7 +137,7 @@ private fun PaxMetricsChart(
ChartStyling.rememberMarker(
valueFormatter =
ChartStyling.createColoredMarkerValueFormatter { value, color ->
when (color.copy(1f)) {
when (color.copy(alpha = 1f)) {
bleColor -> "BLE: %.0f".format(value)
wifiColor -> "WiFi: %.0f".format(value)
paxColor -> "PAX: %.0f".format(value)
@ -210,7 +210,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
}
val dateFormat = DateFormat.getDateTimeInstance()
// Only show logs that can be decoded as PaxcountProtos.Paxcount
// Only show logs that can be decoded as ProtoPaxcount
val paxMetrics =
state.paxMetrics.mapNotNull { log ->
val pax = decodePaxFromLog(log)
@ -225,7 +225,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
paxMetrics
.map {
val t = (it.first.received_date / 1000).toInt()
Triple(t, it.second.ble, it.second.wifi)
Triple(t, it.second.ble ?: 0, it.second.wifi ?: 0)
}
.sortedBy { it.first }
val totalSeries = graphData.map { it.first to (it.second + it.third) }
@ -235,7 +235,7 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.pax_metrics_log) +
" (${paxMetrics.size} ${stringResource(Res.string.logs)})",
@ -324,19 +324,18 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel(), onNav
}
@Suppress("MagicNumber", "CyclomaticComplexMethod")
fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? {
var result: PaxcountProtos.Paxcount? = null
fun decodePaxFromLog(log: MeshLog): ProtoPaxcount? {
var result: ProtoPaxcount? = null
// First, try to parse from the binary fromRadio field (robust, like telemetry)
try {
val packet = log.fromRadio.packet
if (packet != null && packet.hasDecoded() && packet.decoded.portnumValue == PortNum.PAXCOUNTER_APP_VALUE) {
val pax = PaxcountProtos.Paxcount.parseFrom(packet.decoded.payload)
if (pax.ble != 0 || pax.wifi != 0 || pax.uptime != 0) result = pax
val decoded = packet?.decoded
if (packet != null && decoded != null && decoded.portnum == PortNum.PAXCOUNTER_APP) {
val pax = ProtoPaxcount.ADAPTER.decode(decoded.payload)
if ((pax.ble ?: 0) != 0 || (pax.wifi ?: 0) != 0 || (pax.uptime ?: 0) != 0) result = pax
}
} catch (e: com.google.protobuf.InvalidProtocolBufferException) {
} catch (e: Exception) {
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from binary data", e)
} catch (e: IllegalArgumentException) {
android.util.Log.e("PaxMetrics", "Invalid argument while parsing Paxcount from binary data", e)
}
// Fallback: Try direct base64 or bytes from raw_message
if (result == null) {
@ -344,16 +343,14 @@ fun decodePaxFromLog(log: MeshLog): PaxcountProtos.Paxcount? {
val base64 = log.raw_message.trim()
if (base64.matches(Regex("^[A-Za-z0-9+/=\r\n]+$"))) {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
val pax = PaxcountProtos.Paxcount.parseFrom(bytes)
val pax = ProtoPaxcount.ADAPTER.decode(bytes)
result = pax
} else if (base64.matches(Regex("^[0-9a-fA-F]+$")) && base64.length % 2 == 0) {
val bytes = base64.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
val pax = PaxcountProtos.Paxcount.parseFrom(bytes)
val pax = ProtoPaxcount.ADAPTER.decode(bytes)
result = pax
}
} catch (e: IllegalArgumentException) {
android.util.Log.e("PaxMetrics", "Invalid Base64 or hex input", e)
} catch (e: com.google.protobuf.InvalidProtocolBufferException) {
} catch (e: Exception) {
android.util.Log.e("PaxMetrics", "Failed to parse Paxcount from decoded data", e)
}
}
@ -395,13 +392,7 @@ fun PaxcountInfo(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun PaxMetricsItem(
log: MeshLog,
pax: PaxcountProtos.Paxcount,
dateFormat: DateFormat,
isSelected: Boolean,
onClick: () -> Unit,
) {
fun PaxMetricsItem(log: MeshLog, pax: ProtoPaxcount, dateFormat: DateFormat, isSelected: Boolean, onClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -429,19 +420,19 @@ fun PaxMetricsItem(
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
MetricIndicator(PaxSeries.PAX.color)
Spacer(Modifier.width(4.dp))
Text(text = "PAX: ${pax.ble + pax.wifi}", style = MaterialTheme.typography.bodyLarge)
Text(text = "PAX: ${(pax.ble ?: 0) + (pax.wifi ?: 0)}", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.BLE.color)
Spacer(Modifier.width(4.dp))
Text(text = "B:${pax.ble}", style = MaterialTheme.typography.bodyLarge)
Text(text = "B:${pax.ble ?: 0}", style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.width(8.dp))
MetricIndicator(PaxSeries.WIFI.color)
Spacer(Modifier.width(4.dp))
Text(text = "W:${pax.wifi}", style = MaterialTheme.typography.bodyLarge)
Text(text = "W:${pax.wifi ?: 0}", style = MaterialTheme.typography.bodyLarge)
}
Text(
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime),
text = stringResource(Res.string.uptime) + ": " + formatUptime(pax.uptime ?: 0),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.End,
)

View file

@ -83,8 +83,8 @@ import org.meshtastic.core.ui.icon.Save
import org.meshtastic.core.ui.theme.AppTheme
import org.meshtastic.core.ui.util.formatPositionTime
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Config
import org.meshtastic.proto.Position
@Composable
private fun RowScope.PositionText(text: String, weight: Float) {
@ -121,18 +121,18 @@ const val DEG_D = 1e-7
const val HEADING_DEG = 1e-5
@Composable
fun PositionItem(compactWidth: Boolean, position: MeshProtos.Position, system: DisplayUnits) {
fun PositionItem(compactWidth: Boolean, position: Position, system: Config.DisplayConfig.DisplayUnits) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
PositionText("%.5f".format(position.latitudeI * DEG_D), WEIGHT_20)
PositionText("%.5f".format(position.longitudeI * DEG_D), WEIGHT_20)
PositionText(position.satsInView.toString(), WEIGHT_10)
PositionText(position.altitude.metersIn(system).toString(system), WEIGHT_15)
PositionText("%.5f".format((position.latitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText("%.5f".format((position.longitude_i ?: 0) * DEG_D), WEIGHT_20)
PositionText(position.sats_in_view.toString(), WEIGHT_10)
PositionText((position.altitude ?: 0).metersIn(system).toString(system), WEIGHT_15)
if (!compactWidth) {
PositionText("${position.groundSpeed} Km/h", WEIGHT_15)
PositionText("%.0f°".format(position.groundTrack * HEADING_DEG), WEIGHT_15)
PositionText("${position.ground_speed ?: 0} Km/h", WEIGHT_15)
PositionText("%.0f°".format((position.ground_track ?: 0) * HEADING_DEG), WEIGHT_15)
}
PositionText(position.formatPositionTime(), WEIGHT_40)
}
@ -199,7 +199,7 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
ourNode = null,
showNodeChip = false,
canNavigateUp = true,
@ -255,8 +255,8 @@ fun PositionLogScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigateU
@Composable
private fun ColumnScope.PositionList(
compactWidth: Boolean,
positions: List<MeshProtos.Position>,
displayUnits: DisplayUnits,
positions: List<Position>,
displayUnits: Config.DisplayConfig.DisplayUnits,
) {
LazyColumn(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
items(positions) { position -> PositionItem(compactWidth, position, displayUnits) }
@ -265,20 +265,20 @@ private fun ColumnScope.PositionList(
@Suppress("MagicNumber")
private val testPosition =
MeshProtos.Position.newBuilder()
.apply {
latitudeI = 297604270
longitudeI = -953698040
altitude = 1230
satsInView = 7
time = (System.currentTimeMillis() / 1000).toInt()
}
.build()
Position(
latitude_i = 297604270,
longitude_i = -953698040,
altitude = 1230,
sats_in_view = 7,
time = (System.currentTimeMillis() / 1000).toInt(),
)
@Preview(showBackground = true)
@Composable
private fun PositionItemPreview() {
AppTheme { PositionItem(compactWidth = false, position = testPosition, system = DisplayUnits.METRIC) }
AppTheme {
PositionItem(compactWidth = false, position = testPosition, system = Config.DisplayConfig.DisplayUnits.METRIC)
}
}
@PreviewScreenSizes

View file

@ -92,7 +92,7 @@ import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.Telemetry
private enum class PowerMetric(val color: Color) {
CURRENT(InfantryBlue),
@ -149,7 +149,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
Scaffold(
topBar = {
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
subtitle =
stringResource(Res.string.power_metrics_log) + " (${data.size} ${stringResource(Res.string.logs)})",
ourNode = null,
@ -192,7 +192,7 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
selectedX = selectedX,
onPointSelected = { x ->
selectedX = x
val index = data.indexOfFirst { it.time.toDouble() == x }
val index = data.indexOfFirst { (it.time ?: 0).toDouble() == x }
if (index != -1) {
coroutineScope.launch { lazyListState.animateScrollToItem(index) }
}
@ -204,12 +204,12 @@ fun PowerMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigate
itemsIndexed(data) { _, telemetry ->
PowerMetricsCard(
telemetry = telemetry,
isSelected = telemetry.time.toDouble() == selectedX,
isSelected = (telemetry.time ?: 0).toDouble() == selectedX,
onClick = {
selectedX = telemetry.time.toDouble()
selectedX = (telemetry.time ?: 0).toDouble()
coroutineScope.launch {
vicoScrollState.animateScroll(
Scroll.Absolute.x(telemetry.time.toDouble(), 0.5f),
Scroll.Absolute.x((telemetry.time ?: 0).toDouble(), 0.5f),
)
}
},
@ -255,14 +255,14 @@ private fun PowerMetricsChart(
lineSeries {
val currentData = telemetries.filter { !retrieveCurrent(selectedChannel, it).isNaN() }
series(
x = currentData.map { it.time },
x = currentData.map { it.time ?: 0 },
y = currentData.map { retrieveCurrent(selectedChannel, it) },
)
}
lineSeries {
val voltageData = telemetries.filter { !retrieveVoltage(selectedChannel, it).isNaN() }
series(
x = voltageData.map { it.time },
x = voltageData.map { it.time ?: 0 },
y = voltageData.map { retrieveVoltage(selectedChannel, it) },
)
}
@ -319,7 +319,7 @@ private fun PowerMetricsChart(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) {
val time = telemetry.time * MS_PER_SEC
val time = (telemetry.time ?: 0).toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -348,26 +348,17 @@ private fun PowerMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick:
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) {
if (telemetry.powerMetrics.hasCh1Current() || telemetry.powerMetrics.hasCh1Voltage()) {
PowerChannelColumn(
Res.string.channel_1,
telemetry.powerMetrics.ch1Voltage,
telemetry.powerMetrics.ch1Current,
)
}
if (telemetry.powerMetrics.hasCh2Current() || telemetry.powerMetrics.hasCh2Voltage()) {
PowerChannelColumn(
Res.string.channel_2,
telemetry.powerMetrics.ch2Voltage,
telemetry.powerMetrics.ch2Current,
)
}
if (telemetry.powerMetrics.hasCh3Current() || telemetry.powerMetrics.hasCh3Voltage()) {
PowerChannelColumn(
Res.string.channel_3,
telemetry.powerMetrics.ch3Voltage,
telemetry.powerMetrics.ch3Current,
)
val pm = telemetry.power_metrics
if (pm != null) {
if (pm.ch1_current != null || pm.ch1_voltage != null) {
PowerChannelColumn(Res.string.channel_1, pm.ch1_voltage ?: 0f, pm.ch1_current ?: 0f)
}
if (pm.ch2_current != null || pm.ch2_voltage != null) {
PowerChannelColumn(Res.string.channel_2, pm.ch2_voltage ?: 0f, pm.ch2_current ?: 0f)
}
if (pm.ch3_current != null || pm.ch3_voltage != null) {
PowerChannelColumn(Res.string.channel_3, pm.ch3_voltage ?: 0f, pm.ch3_current ?: 0f)
}
}
}
}
@ -408,14 +399,14 @@ private fun PowerChannelColumn(titleRes: StringResource, voltage: Float, current
/** Retrieves the appropriate voltage depending on `channelSelected`. */
private fun retrieveVoltage(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.powerMetrics.ch1Voltage
PowerChannel.TWO -> telemetry.powerMetrics.ch2Voltage
PowerChannel.THREE -> telemetry.powerMetrics.ch3Voltage
PowerChannel.ONE -> telemetry.power_metrics?.ch1_voltage ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_voltage ?: Float.NaN
PowerChannel.THREE -> telemetry.power_metrics?.ch3_voltage ?: Float.NaN
}
/** Retrieves the appropriate current depending on `channelSelected`. */
private fun retrieveCurrent(channelSelected: PowerChannel, telemetry: Telemetry): Float = when (channelSelected) {
PowerChannel.ONE -> telemetry.powerMetrics.ch1Current
PowerChannel.TWO -> telemetry.powerMetrics.ch2Current
PowerChannel.THREE -> telemetry.powerMetrics.ch3Current
PowerChannel.ONE -> telemetry.power_metrics?.ch1_current ?: Float.NaN
PowerChannel.TWO -> telemetry.power_metrics?.ch2_current ?: Float.NaN
PowerChannel.THREE -> telemetry.power_metrics?.ch3_current ?: Float.NaN
}

View file

@ -68,7 +68,7 @@ import org.meshtastic.core.ui.theme.GraphColors.Blue
import org.meshtastic.core.ui.theme.GraphColors.Green
import org.meshtastic.feature.node.metrics.CommonCharts.DATE_TIME_FORMAT
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshProtos.MeshPacket
import org.meshtastic.proto.MeshPacket
private enum class SignalMetric(val color: Color) {
SNR(Green),
@ -93,7 +93,7 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
telemetryType = TelemetryType.LOCAL_STATS,
titleRes = Res.string.signal_quality,
data = data,
timeProvider = { it.rxTime.toDouble() },
timeProvider = { (it.rx_time ?: 0).toDouble() },
infoData =
listOf(
InfoDialogData(Res.string.snr, Res.string.snr_definition, SignalMetric.SNR.color),
@ -113,8 +113,8 @@ fun SignalMetricsScreen(viewModel: MetricsViewModel = hiltViewModel(), onNavigat
itemsIndexed(data) { _, meshPacket ->
SignalMetricsCard(
meshPacket = meshPacket,
isSelected = meshPacket.rxTime.toDouble() == selectedX,
onClick = { onCardClick(meshPacket.rxTime.toDouble()) },
isSelected = (meshPacket.rx_time ?: 0).toDouble() == selectedX,
onClick = { onCardClick((meshPacket.rx_time ?: 0).toDouble()) },
)
}
}
@ -140,12 +140,12 @@ private fun SignalMetricsChart(
modelProducer.runTransaction {
/* Use separate lineSeries calls to associate them with different vertical axes */
lineSeries {
val rssiData = meshPackets.filter { it.rxRssi != 0 && !it.rxRssi.toFloat().isNaN() }
series(x = rssiData.map { it.rxTime }, y = rssiData.map { it.rxRssi })
val rssiData = meshPackets.filter { (it.rx_rssi ?: 0) != 0 }
series(x = rssiData.map { it.rx_time ?: 0 }, y = rssiData.map { it.rx_rssi ?: 0 })
}
lineSeries {
val snrData = meshPackets.filter { !it.rxSnr.isNaN() }
series(x = snrData.map { it.rxTime }, y = snrData.map { it.rxSnr })
val snrData = meshPackets.filter { !((it.rx_snr ?: Float.NaN).isNaN()) }
series(x = snrData.map { it.rx_time ?: 0 }, y = snrData.map { it.rx_snr ?: 0f })
}
}
}
@ -215,7 +215,7 @@ private fun SignalMetricsChart(
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onClick: () -> Unit) {
val time = meshPacket.rxTime * MS_PER_SEC
val time = (meshPacket.rx_time ?: 0).toLong() * MS_PER_SEC
Card(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp).clickable { onClick() },
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
@ -250,14 +250,14 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
MetricIndicator(SignalMetric.RSSI.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.0f dBm".format(meshPacket.rxRssi.toFloat()),
text = "%.0f dBm".format((meshPacket.rx_rssi ?: 0).toFloat()),
style = MaterialTheme.typography.labelLarge,
)
Spacer(Modifier.width(12.dp))
MetricIndicator(SignalMetric.SNR.color)
Spacer(Modifier.width(4.dp))
Text(
text = "%.1f dB".format(meshPacket.rxSnr),
text = "%.1f dB".format(meshPacket.rx_snr ?: 0f),
style = MaterialTheme.typography.labelLarge,
)
}
@ -266,7 +266,7 @@ private fun SignalMetricsCard(meshPacket: MeshPacket, isSelected: Boolean, onCli
/* Signal Indicator */
Box(modifier = Modifier.weight(weight = 3f).height(IntrinsicSize.Max)) {
LoraSignalIndicator(meshPacket.rxSnr, meshPacket.rxRssi)
LoraSignalIndicator(meshPacket.rx_snr ?: 0f, meshPacket.rx_rssi ?: 0)
}
}
}

View file

@ -90,7 +90,8 @@ import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.feature.node.component.CooldownIconButton
import org.meshtastic.feature.node.detail.NodeRequestEffect
import org.meshtastic.feature.node.metrics.CommonCharts.MS_PER_SEC
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Position
import org.meshtastic.proto.RouteDiscovery
private data class TracerouteDialog(
val message: AnnotatedString,
@ -122,7 +123,8 @@ fun TracerouteLogScreen(
}
}
fun getUsername(nodeNum: Int): String = with(viewModel.getUser(nodeNum)) { "$longName ($shortName)" }
fun getUsername(nodeNum: Int): String =
with(viewModel.getUser(nodeNum)) { "${long_name ?: ""} (${short_name ?: ""})" }
var showDialog by remember { mutableStateOf<TracerouteDialog?>(null) }
var errorMessageRes by remember { mutableStateOf<StringResource?>(null) }
@ -142,7 +144,7 @@ fun TracerouteLogScreen(
topBar = {
val lastTracerouteTime by viewModel.lastTraceRouteTime.collectAsState()
MainAppBar(
title = state.node?.user?.longName ?: "",
title = state.node?.user?.long_name ?: "",
subtitle = stringResource(Res.string.traceroute_log),
ourNode = null,
showNodeChip = false,
@ -169,9 +171,9 @@ fun TracerouteLogScreen(
) {
items(state.tracerouteRequests, key = { it.uuid }) { log ->
val result =
remember(state.tracerouteRequests, log.fromRadio.packet.id) {
remember(state.tracerouteRequests, log.fromRadio.packet?.id) {
state.tracerouteResults.find {
it.fromRadio.packet.decoded.requestId == log.fromRadio.packet.id
it.fromRadio.packet?.decoded?.request_id == log.fromRadio.packet?.id
}
}
val route = remember(result) { result?.fromRadio?.packet?.fullRouteDiscovery }
@ -187,12 +189,12 @@ fun TracerouteLogScreen(
val tracerouteDetailsAnnotated: AnnotatedString? =
result?.let { res ->
if (route != null && route.routeList.isNotEmpty() && route.routeBackList.isNotEmpty()) {
if (route != null && route.route.isNotEmpty() && route.route_back.isNotEmpty()) {
val seconds =
(res.received_date - log.received_date).coerceAtLeast(0).toDouble() / MS_PER_SEC
val annotatedBase =
annotateTraceroute(
res.fromRadio.packet.getTracerouteResponse(
res.fromRadio.packet?.getTracerouteResponse(
::getUsername,
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
@ -206,7 +208,7 @@ fun TracerouteLogScreen(
} else {
// For cases where there's a result but no full route, display plain text
res.fromRadio.packet
.getTracerouteResponse(
?.getTracerouteResponse(
::getUsername,
headerTowards = stringResource(Res.string.traceroute_route_towards_dest),
headerBack = stringResource(Res.string.traceroute_route_back_to_us),
@ -217,9 +219,9 @@ fun TracerouteLogScreen(
val overlay =
route?.let {
TracerouteOverlay(
requestId = log.fromRadio.packet.id,
forwardRoute = it.routeList,
returnRoute = it.routeBackList,
requestId = log.fromRadio.packet?.id ?: 0,
forwardRoute = it.route,
returnRoute = it.route_back,
)
}
@ -246,7 +248,7 @@ fun TracerouteLogScreen(
showDialog =
TracerouteDialog(
message = it,
requestId = log.fromRadio.packet.id,
requestId = log.fromRadio.packet?.id ?: 0,
responseLogUuid = responseLogUuid,
overlay = overlay,
)
@ -278,7 +280,7 @@ private fun TracerouteLogDialogs(
dialog?.let { dialogState ->
val snapshotPositionsFlow =
remember(dialogState.responseLogUuid) { viewModel.tracerouteSnapshotPositions(dialogState.responseLogUuid) }
val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap<Int, MeshProtos.Position>())
val snapshotPositions by snapshotPositionsFlow.collectAsStateWithLifecycle(emptyMap<Int, Position>())
SimpleAlertDialog(
title = Res.string.traceroute,
text = { SelectionContainer { Text(text = dialogState.message) } },
@ -316,24 +318,24 @@ private fun TracerouteLogDialogs(
/** Generates a display string and icon based on the route discovery information. */
@Composable
private fun MeshProtos.RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
private fun RouteDiscovery?.getTextAndIcon(): Pair<String, ImageVector> = when {
this == null -> {
stringResource(Res.string.routing_error_no_response) to MeshtasticIcons.PersonOff
}
// A direct route means the sender and receiver are the only two nodes in the route.
routeCount <= 2 && routeBackCount <= 2 -> { // also check routeBackCount for direct to be more robust
route.size <= 2 && route_back.size <= 2 -> { // also check route_back size for direct to be more robust
stringResource(Res.string.traceroute_direct) to MeshtasticIcons.Group
}
routeCount == routeBackCount -> {
val hops = routeCount - 2
route.size == route_back.size -> {
val hops = route.size - 2
pluralStringResource(Res.plurals.traceroute_hops, hops, hops) to MeshtasticIcons.Route
}
else -> {
// Asymmetric route
val towards = maxOf(0, routeCount - 2)
val back = maxOf(0, routeBackCount - 2)
val towards = maxOf(0, route.size - 2)
val back = maxOf(0, route_back.size - 2)
stringResource(Res.string.traceroute_diff, towards, back) to MeshtasticIcons.Route
}
}

View file

@ -54,7 +54,7 @@ import org.meshtastic.core.ui.icon.Route
import org.meshtastic.core.ui.theme.TracerouteColors
import org.meshtastic.feature.map.MapView
import org.meshtastic.feature.map.model.TracerouteOverlay
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.Position
@Composable
fun TracerouteMapScreen(
@ -66,27 +66,26 @@ fun TracerouteMapScreen(
val state by metricsViewModel.state.collectAsStateWithLifecycle()
val snapshotPositions by
remember(logUuid) {
logUuid?.let(metricsViewModel::tracerouteSnapshotPositions)
?: flowOf(emptyMap<Int, MeshProtos.Position>())
logUuid?.let(metricsViewModel::tracerouteSnapshotPositions) ?: flowOf(emptyMap<Int, Position>())
}
.collectAsStateWithLifecycle(emptyMap<Int, MeshProtos.Position>())
.collectAsStateWithLifecycle(emptyMap<Int, Position>())
val tracerouteResult =
if (logUuid != null) {
state.tracerouteResults.find { it.uuid == logUuid }
} else {
state.tracerouteResults.find { it.fromRadio.packet.decoded.requestId == requestId }
state.tracerouteResults.find { it.fromRadio.packet?.decoded?.request_id == requestId }
}
val routeDiscovery = tracerouteResult?.fromRadio?.packet?.fullRouteDiscovery
val overlayFromLogs =
remember(routeDiscovery, requestId) {
routeDiscovery?.let { TracerouteOverlay(requestId, it.routeList, it.routeBackList) }
routeDiscovery?.let { TracerouteOverlay(requestId, it.route, it.route_back) }
}
val overlayFromService = remember(requestId) { metricsViewModel.getTracerouteOverlay(requestId) }
val overlay = overlayFromLogs ?: overlayFromService
LaunchedEffect(Unit) { metricsViewModel.clearTracerouteResponse() }
TracerouteMapScaffold(
title = state.node?.user?.longName ?: stringResource(Res.string.traceroute),
title = state.node?.user?.long_name ?: stringResource(Res.string.traceroute),
overlay = overlay,
snapshotPositions = snapshotPositions,
onNavigateUp = onNavigateUp,
@ -97,7 +96,7 @@ fun TracerouteMapScreen(
private fun TracerouteMapScaffold(
title: String,
overlay: TracerouteOverlay?,
snapshotPositions: Map<Int, MeshProtos.Position>,
snapshotPositions: Map<Int, Position>,
onNavigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,16 +14,10 @@
* 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.feature.node.model
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.database.model.isUnmessageableRole
val Node.isEffectivelyUnmessageable: Boolean
get() =
if (user.hasIsUnmessagable()) {
user.isUnmessagable
} else {
user.role?.isUnmessageableRole() == true
}
get() = user.is_unmessagable ?: (user.role?.isUnmessageableRole() == true)

View file

@ -20,28 +20,29 @@ import org.meshtastic.core.database.entity.FirmwareRelease
import org.meshtastic.core.database.entity.MeshLog
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.model.DeviceHardware
import org.meshtastic.proto.ConfigProtos
import org.meshtastic.proto.MeshProtos
import org.meshtastic.proto.TelemetryProtos
import org.meshtastic.proto.Config
import org.meshtastic.proto.FirmwareEdition
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
data class MetricsState(
val isLocal: Boolean = false,
val isManaged: Boolean = true,
val isFahrenheit: Boolean = false,
val displayUnits: ConfigProtos.Config.DisplayConfig.DisplayUnits =
ConfigProtos.Config.DisplayConfig.DisplayUnits.METRIC,
val displayUnits: Config.DisplayConfig.DisplayUnits = Config.DisplayConfig.DisplayUnits.METRIC,
val node: Node? = null,
val deviceMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val signalMetrics: List<MeshProtos.MeshPacket> = emptyList(),
val powerMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val hostMetrics: List<TelemetryProtos.Telemetry> = emptyList(),
val deviceMetrics: List<Telemetry> = emptyList(),
val signalMetrics: List<MeshPacket> = emptyList(),
val powerMetrics: List<Telemetry> = emptyList(),
val hostMetrics: List<Telemetry> = emptyList(),
val tracerouteRequests: List<MeshLog> = emptyList(),
val tracerouteResults: List<MeshLog> = emptyList(),
val neighborInfoRequests: List<MeshLog> = emptyList(),
val neighborInfoResults: List<MeshLog> = emptyList(),
val positionLogs: List<MeshProtos.Position> = emptyList(),
val positionLogs: List<Position> = emptyList(),
val deviceHardware: DeviceHardware? = null,
val firmwareEdition: MeshProtos.FirmwareEdition? = null,
val firmwareEdition: FirmwareEdition? = null,
val latestStableFirmware: FirmwareRelease = FirmwareRelease(),
val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(),
val paxMetrics: List<MeshLog> = emptyList(),

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Meshtastic LLC
* Copyright (c) 2025-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
@ -14,14 +14,13 @@
* 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.feature.node.model
import org.meshtastic.core.database.model.Node
import org.meshtastic.core.navigation.Route
import org.meshtastic.core.service.ServiceAction
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits
import org.meshtastic.proto.Config
sealed interface NodeDetailAction {
data class Navigate(val route: Route) : NodeDetailAction
@ -33,5 +32,5 @@ sealed interface NodeDetailAction {
data object ShareContact : NodeDetailAction
// Opens the compass sheet scoped to a target node and the users preferred units.
data class OpenCompass(val node: Node, val displayUnits: DisplayUnits) : NodeDetailAction
data class OpenCompass(val node: Node, val displayUnits: Config.DisplayConfig.DisplayUnits) : NodeDetailAction
}

View file

@ -19,8 +19,8 @@ package org.meshtastic.feature.node.metrics
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.proto.TelemetryProtos.EnvironmentMetrics
import org.meshtastic.proto.TelemetryProtos.Telemetry
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.Telemetry
class EnvironmentMetricsStateTest {
@ -29,18 +29,9 @@ class EnvironmentMetricsStateTest {
val now = (System.currentTimeMillis() / 1000).toInt()
val metrics =
listOf(
Telemetry.newBuilder()
.setTime(now - 100)
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(20f))
.build(),
Telemetry.newBuilder()
.setTime(now - 50)
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(22f))
.build(),
Telemetry.newBuilder()
.setTime(now)
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(21f))
.build(),
Telemetry(time = now - 100, environment_metrics = EnvironmentMetrics(temperature = 20f)),
Telemetry(time = now - 50, environment_metrics = EnvironmentMetrics(temperature = 22f)),
Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 21f)),
)
val state = EnvironmentMetricsState(metrics)
val result = state.environmentMetricsForGraphing()
@ -52,13 +43,7 @@ class EnvironmentMetricsStateTest {
@Test
fun `environmentMetricsForGraphing handles valid zero temperatures`() {
val now = (System.currentTimeMillis() / 1000).toInt()
val metrics =
listOf(
Telemetry.newBuilder()
.setTime(now)
.setEnvironmentMetrics(EnvironmentMetrics.newBuilder().setTemperature(0.0f))
.build(),
)
val metrics = listOf(Telemetry(time = now, environment_metrics = EnvironmentMetrics(temperature = 0.0f)))
val state = EnvironmentMetricsState(metrics)
val result = state.environmentMetricsForGraphing()