diff --git a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt index 27498e470..b4d3e7d44 100644 --- a/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt +++ b/app/src/main/java/com/geeksville/mesh/database/entity/NodeEntity.kt @@ -72,9 +72,15 @@ data class NodeEntity( val environmentMetrics: TelemetryProtos.EnvironmentMetrics get() = environmentTelemetry.environmentMetrics + val hasEnvironmentMetrics: Boolean + get() = environmentMetrics != TelemetryProtos.EnvironmentMetrics.getDefaultInstance() + val powerMetrics: TelemetryProtos.PowerMetrics get() = powerTelemetry.powerMetrics + val hasPowerMetrics: Boolean + get() = powerMetrics != TelemetryProtos.PowerMetrics.getDefaultInstance() + val colors: Pair get() { // returns foreground and background @ColorInt for each 'num' val r = (num and 0xFF0000) shr 16 @@ -145,8 +151,6 @@ data class NodeEntity( null } val humidity = if (relativeHumidity != 0f) "%.0f%%".format(relativeHumidity) else null - val pressure = if (barometricPressure != 0f) "%.1fhPa".format(barometricPressure) else null - val gas = if (gasResistance != 0f) "%.0fMΩ".format(gasResistance) else null val voltage = if (this.voltage != 0f) "%.2fV".format(this.voltage) else null val current = if (current != 0f) "%.1fmA".format(current) else null val iaq = if (iaq != 0) "IAQ: $iaq" else null @@ -154,21 +158,12 @@ data class NodeEntity( return listOfNotNull( temp, humidity, - pressure, - gas, voltage, current, iaq, ).joinToString(" ") } - private fun TelemetryProtos.PowerMetrics.getDisplayString(): String = listOfNotNull( - "%.2fV".format(ch2Voltage).takeIf { hasCh2Voltage() }, - "%.1fmA".format(ch2Current).takeIf { hasCh2Current() }, - "%.2fV".format(ch3Voltage).takeIf { hasCh3Voltage() }, - "%.1fmA".format(ch3Current).takeIf { hasCh3Current() }, - ).joinToString(" ") - private fun PaxcountProtos.Paxcount.getDisplayString() = "PAX: ${ble + wifi} (B:$ble/W:$wifi)".takeIf { ble != 0 && wifi != 0 } @@ -176,7 +171,6 @@ data class NodeEntity( return listOfNotNull( paxcounter.getDisplayString(), environmentMetrics.getDisplayString(isFahrenheit), - powerMetrics.getDisplayString(), ).joinToString(" ") } diff --git a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt index 7294a0228..174e25008 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/NodeDetailsScreen.kt @@ -1,37 +1,57 @@ +@file:Suppress("TooManyFunctions") + package com.geeksville.mesh.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Air +import androidx.compose.material.icons.filled.BlurOn +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.ChargingStation import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyOff import androidx.compose.material.icons.filled.Numbers import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Power import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Thermostat +import androidx.compose.material.icons.filled.WaterDrop import androidx.compose.material.icons.filled.Work import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -43,6 +63,7 @@ import com.geeksville.mesh.ui.preview.NodeEntityPreviewParameterProvider import com.geeksville.mesh.ui.theme.AppTheme import com.geeksville.mesh.util.formatAgo import java.util.concurrent.TimeUnit +import kotlin.math.ln @Composable fun NodeDetailsScreen( @@ -73,9 +94,8 @@ fun NodeDetailsScreen( } } -@Suppress("LongMethod") @Composable -fun NodeDetailsItemList( +private fun NodeDetailsItemList( node: NodeEntity, metricsState: MetricsState, modifier: Modifier = Modifier, @@ -87,58 +107,23 @@ fun NodeDetailsItemList( ) { item { PreferenceCategory("Details") { - if (node.mismatchKey) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.KeyOff, - contentDescription = stringResource(id = R.string.encryption_error), - tint = Color.Red, - ) - Column(modifier = Modifier.padding(start = 8.dp)) { - Text( - text = stringResource(id = R.string.encryption_error), - style = MaterialTheme.typography.h6.copy(color = Color.Red) - ) - Text( - text = stringResource(id = R.string.encryption_error_text), - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) - ) - } - } - } + NodeDetailsContent(node) + } + } - NodeDetailRow( - label = "Node Number", - icon = Icons.Default.Numbers, - value = node.num.toUInt().toString() - ) + if (node.hasEnvironmentMetrics) { + item { + PreferenceCategory("Environment") + EnvironmentMetrics(node, metricsState.environmentDisplayFahrenheit) + Spacer(modifier = Modifier.height(8.dp)) + } + } - NodeDetailRow( - label = "User Id", - icon = Icons.Default.Person, - value = node.user.id - ) - - NodeDetailRow( - label = "Role", - icon = Icons.Default.Work, - value = node.user.role.name - ) - - if (node.deviceMetrics.uptimeSeconds > 0) { - NodeDetailRow( - label = "Uptime", - icon = Icons.Default.CheckCircle, - value = formatUptime(node.deviceMetrics.uptimeSeconds) - ) - } - - NodeDetailRow( - label = "Last heard", - icon = Icons.Default.History, - value = formatAgo(node.lastHeard) - ) + if (node.hasPowerMetrics) { + item { + PreferenceCategory("Power") + PowerMetrics(node) + Spacer(modifier = Modifier.height(8.dp)) } } @@ -171,7 +156,7 @@ fun NodeDetailsItemList( } @Composable -fun NodeDetailRow(label: String, icon: ImageVector, value: String) { +private fun NodeDetailRow(label: String, icon: ImageVector, value: String) { Row( modifier = Modifier .fillMaxWidth() @@ -190,6 +175,107 @@ fun NodeDetailRow(label: String, icon: ImageVector, value: String) { } } +@Composable +private fun NodeDetailsContent(node: NodeEntity) { + if (node.mismatchKey) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.KeyOff, + contentDescription = stringResource(id = R.string.encryption_error), + tint = Color.Red, + ) + Column(modifier = Modifier.padding(start = 8.dp)) { + Text( + text = stringResource(id = R.string.encryption_error), + style = MaterialTheme.typography.h6.copy(color = Color.Red) + ) + Text( + text = stringResource(id = R.string.encryption_error_text), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.6f) + ) + } + } + } + NodeDetailRow( + label = "Node Number", + icon = Icons.Default.Numbers, + value = node.num.toUInt().toString() + ) + NodeDetailRow( + label = "User Id", + icon = Icons.Default.Person, + value = node.user.id + ) + NodeDetailRow( + label = "Role", + icon = Icons.Default.Work, + value = node.user.role.name + ) + if (node.deviceMetrics.uptimeSeconds > 0) { + NodeDetailRow( + label = "Uptime", + icon = Icons.Default.CheckCircle, + value = formatUptime(node.deviceMetrics.uptimeSeconds) + ) + } + NodeDetailRow( + label = "Last heard", + icon = Icons.Default.History, + value = formatAgo(node.lastHeard) + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun InfoRow(content: @Composable () -> Unit) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.SpaceEvenly, + ) { content() } +} + +@Composable +private fun InfoCard( + icon: Painter, + text: String, + value: String, +) { + Card( + shape = RoundedCornerShape(12.dp), + backgroundColor = MaterialTheme.colors.surface, + elevation = 4.dp, + modifier = Modifier + .padding(4.dp) + .widthIn(min = 100.dp, max = 150.dp) + .heightIn(min = 100.dp, max = 150.dp) + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + painter = icon, + contentDescription = text, + modifier = Modifier.size(24.dp), + ) + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.subtitle2 + ) + Text( + text = value, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.h5 + ) + } + } +} + private fun formatUptime(seconds: Int): String = formatUptime(seconds.toLong()) private fun formatUptime(seconds: Long): String { @@ -206,6 +292,137 @@ private fun formatUptime(seconds: Long): String { ).joinToString(" ") } +@Suppress("LongMethod") +@Composable +private fun EnvironmentMetrics( + node: NodeEntity, + isFahrenheit: Boolean = false, +) = with(node.environmentMetrics) { + InfoRow { + if (temperature > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Thermostat), + text = "Temperature", + value = temperature.toTempString(isFahrenheit) + ) + } + if (relativeHumidity > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.WaterDrop), + text = "Humidity", + value = "%.0f%%".format(relativeHumidity) + ) + } + if (temperature > 0 && relativeHumidity > 0) { + val dewPoint = calculateDewPoint(temperature, relativeHumidity) + InfoCard( + icon = painterResource(R.drawable.ic_outlined_dew_point_24), + text = "Dew Point", + value = dewPoint.toTempString(isFahrenheit) + ) + } + if (barometricPressure > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Speed), + text = "Pressure", + value = "%.0f".format(barometricPressure) + ) + } + if (gasResistance > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.BlurOn), + text = "Gas Resistance", + value = "%.0f".format(gasResistance) + ) + } + if (voltage > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Bolt), + text = "Voltage", + value = "%.1fV".format(voltage) + ) + } + if (current > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Power), + text = "Current", + value = "%.1fA".format(current) + ) + } + if (iaq > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Air), + text = "IAQ", + value = iaq.toString() + ) + } + } +} + +@Suppress("MagicNumber") +private fun Float.toTempString(isFahrenheit: Boolean) = if (isFahrenheit) { + val fahrenheit = this * 1.8F + 32 + "%.0f°F".format(fahrenheit) +} else { + "%.0f°C".format(this) +} + +// Magnus-Tetens approximation +@Suppress("MagicNumber") +private fun calculateDewPoint(tempCelsius: Float, humidity: Float): Float { + val (a, b) = 17.27f to 237.7f + val alpha = (a * tempCelsius) / (b + tempCelsius) + ln(humidity / 100f) + return (b * alpha) / (a - alpha) +} + +@Composable +private fun PowerMetrics(node: NodeEntity) = with(node.powerMetrics) { + InfoRow { + if (ch1Voltage > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Bolt), + text = "Voltage", + value = "%.1fV".format(ch1Voltage) + ) + } + if (ch1Current > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Power), + text = "Current", + value = "%.1fA".format(ch1Current) + ) + } + if (ch2Voltage > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Bolt), + text = "Voltage", + value = "%.1fV".format(ch2Voltage) + ) + } + if (ch2Current > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Power), + text = "Current", + value = "%.1fA".format(ch2Current) + ) + } + if (ch3Voltage > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Bolt), + text = "Voltage", + value = "%.1fV".format(ch3Voltage) + ) + } + if (ch3Current > 0) { + InfoCard( + icon = rememberVectorPainter(Icons.Default.Power), + text = "Current", + value = "%.1fA".format(ch3Current) + ) + } + } +} + @Preview(showBackground = true) @Composable private fun NodeDetailsPreview( diff --git a/app/src/main/res/drawable/ic_outlined_dew_point_24.xml b/app/src/main/res/drawable/ic_outlined_dew_point_24.xml new file mode 100644 index 000000000..e19263e2e --- /dev/null +++ b/app/src/main/res/drawable/ic_outlined_dew_point_24.xml @@ -0,0 +1,9 @@ + + +