kopia lustrzana https://github.com/meshtastic/Meshtastic-Android
feat: add info cards for environment and power metrics
rodzic
46b3146d6d
commit
c98a5c7caf
app/src/main
java/com/geeksville/mesh
database/entity
res/drawable
|
@ -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<Int, Int>
|
||||
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(" ")
|
||||
}
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
android:pathData="M620,440q-25,0 -42.5,-17.5T560,380q0,-17 9.5,-34.5t20.5,-32q11,-14.5 20.5,-24l9.5,-9.5 9.5,9.5q9.5,9.5 20.5,24t20.5,32Q680,363 680,380q0,25 -17.5,42.5T620,440ZM780,320q-25,0 -42.5,-17.5T720,260q0,-17 9.5,-34.5t20.5,-32q11,-14.5 20.5,-24l9.5,-9.5 9.5,9.5q9.5,9.5 20.5,24t20.5,32Q840,243 840,260q0,25 -17.5,42.5T780,320ZM780,560q-25,0 -42.5,-17.5T720,500q0,-17 9.5,-34.5t20.5,-32q11,-14.5 20.5,-24l9.5,-9.5 9.5,9.5q9.5,9.5 20.5,24t20.5,32Q840,483 840,500q0,25 -17.5,42.5T780,560ZM360,840q-83,0 -141.5,-58.5T160,640q0,-48 21,-89.5t59,-70.5v-240q0,-50 35,-85t85,-35q50,0 85,35t35,85v240q38,29 59,70.5t21,89.5q0,83 -58.5,141.5T360,840ZM240,640h240q0,-29 -12.5,-54T432,544l-32,-24v-280q0,-17 -11.5,-28.5T360,200q-17,0 -28.5,11.5T320,240v280l-32,24q-23,17 -35.5,42T240,640Z"
|
||||
android:fillColor="#e8eaed"/>
|
||||
</vector>
|
Ładowanie…
Reference in New Issue