mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
feat(wire): migrate from protobuf -> wire (#4401)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
9dbc8b7fbf
commit
25657e8f8f
239 changed files with 7149 additions and 6144 deletions
|
|
@ -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: () -> 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<Telemetry>, legendData: List<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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)!! })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 user’s preferred units.
|
||||
data class OpenCompass(val node: Node, val displayUnits: DisplayUnits) : NodeDetailAction
|
||||
data class OpenCompass(val node: Node, val displayUnits: Config.DisplayConfig.DisplayUnits) : NodeDetailAction
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue