Fix/2523 redundant soil metrics (#2556)

Signed-off-by: DaneEvans <dane@goneepic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
DaneEvans 2025-07-31 06:54:14 +10:00 committed by GitHub
parent edcf0f11eb
commit 1ba70ca547
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 823 additions and 449 deletions

View file

@ -150,18 +150,37 @@ data class DeviceMetrics(
@Parcelize
data class EnvironmentMetrics(
val time: Int = currentTime(), // default to current time in secs (NOT MILLISECONDS!)
val temperature: Float,
val relativeHumidity: Float,
val soilTemperature: Float,
val soilMoisture: Int,
val barometricPressure: Float,
val gasResistance: Float,
val voltage: Float,
val current: Float,
val iaq: Int,
val temperature: Float?,
val relativeHumidity: Float?,
val soilTemperature: Float?,
val soilMoisture: Int?,
val barometricPressure: Float?,
val gasResistance: Float?,
val voltage: Float?,
val current: Float?,
val iaq: Int?,
val lux: Float? = null,
val uvLux: Float? = null,
) : Parcelable {
companion object {
fun currentTime() = (System.currentTimeMillis() / 1000).toInt()
fun fromTelemetryProto(proto: TelemetryProtos.EnvironmentMetrics, time: Int): EnvironmentMetrics =
EnvironmentMetrics(
temperature = proto.temperature.takeIf { proto.hasTemperature() && !it.isNaN() },
relativeHumidity =
proto.relativeHumidity.takeIf { proto.hasRelativeHumidity() && !it.isNaN() && it != 0.0f },
soilTemperature = proto.soilTemperature.takeIf { proto.hasSoilTemperature() && !it.isNaN() },
soilMoisture = proto.soilMoisture.takeIf { proto.hasSoilMoisture() && it != Int.MIN_VALUE },
barometricPressure = proto.barometricPressure.takeIf { proto.hasBarometricPressure() && !it.isNaN() },
gasResistance = proto.gasResistance.takeIf { proto.hasGasResistance() && !it.isNaN() },
voltage = proto.voltage.takeIf { proto.hasVoltage() && !it.isNaN() },
current = proto.current.takeIf { proto.hasCurrent() && !it.isNaN() },
iaq = proto.iaq.takeIf { proto.hasIaq() && it != Int.MIN_VALUE },
lux = proto.lux.takeIf { proto.hasLux() && !it.isNaN() },
uvLux = proto.uvLux.takeIf { proto.hasUvLux() && !it.isNaN() },
time = time,
)
}
}

View file

@ -76,7 +76,7 @@ class Converters : Logging {
TelemetryProtos.Telemetry.parseFrom(bytes)
} catch (ex: InvalidProtocolBufferException) {
errormsg("bytesToTelemetry TypeConverter error:", ex)
TelemetryProtos.Telemetry.getDefaultInstance()
TelemetryProtos.Telemetry.newBuilder().build() // Return an empty Telemetry object
}
@TypeConverter fun telemetryToBytes(value: TelemetryProtos.Telemetry): ByteArray? = value.toByteArray()

View file

@ -50,6 +50,44 @@ constructor(
private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching {
Telemetry.parseFrom(log.fromRadio.packet.decoded.payload)
.toBuilder()
.apply {
// Handle float metrics that default to 0.0f when not explicitly set or when 0.0f means no data
if (!environmentMetrics.hasTemperature()) {
environmentMetrics = environmentMetrics.toBuilder().setTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasRelativeHumidity()) {
environmentMetrics = environmentMetrics.toBuilder().setRelativeHumidity(Float.NaN).build()
}
if (!environmentMetrics.hasSoilTemperature()) {
environmentMetrics = environmentMetrics.toBuilder().setSoilTemperature(Float.NaN).build()
}
if (!environmentMetrics.hasBarometricPressure()) {
environmentMetrics = environmentMetrics.toBuilder().setBarometricPressure(Float.NaN).build()
}
if (!environmentMetrics.hasGasResistance()) {
environmentMetrics = environmentMetrics.toBuilder().setGasResistance(Float.NaN).build()
}
if (!environmentMetrics.hasVoltage()) {
environmentMetrics = environmentMetrics.toBuilder().setVoltage(Float.NaN).build()
}
if (!environmentMetrics.hasCurrent()) {
environmentMetrics = environmentMetrics.toBuilder().setCurrent(Float.NaN).build()
}
if (!environmentMetrics.hasLux()) {
environmentMetrics = environmentMetrics.toBuilder().setLux(Float.NaN).build()
}
if (!environmentMetrics.hasUvLux()) {
environmentMetrics = environmentMetrics.toBuilder().setUvLux(Float.NaN).build()
}
// Handle uint32 metrics that default to 0 when not explicitly set or when 0 means no data
if (!environmentMetrics.hasIaq()) {
environmentMetrics = environmentMetrics.toBuilder().setIaq(Int.MIN_VALUE).build()
}
if (!environmentMetrics.hasSoilMoisture()) {
environmentMetrics = environmentMetrics.toBuilder().setSoilMoisture(Int.MIN_VALUE).build()
}
}
.setTime((log.received_date / MILLIS_TO_SECONDS).toInt())
.build()
}

View file

@ -113,7 +113,7 @@ data class NodeEntity(
@ColumnInfo(name = "is_favorite") var isFavorite: Boolean = false,
@ColumnInfo(name = "is_ignored", defaultValue = "0") var isIgnored: Boolean = false,
@ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB)
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
var environmentTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.newBuilder().build(),
@ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB)
var powerTelemetry: TelemetryProtos.Telemetry = TelemetryProtos.Telemetry.getDefaultInstance(),
@ColumnInfo(typeAffinity = ColumnInfo.BLOB)
@ -192,17 +192,9 @@ data class NodeEntity(
),
channel = channel,
environmentMetrics =
EnvironmentMetrics(
time = environmentTelemetry.time,
temperature = environmentMetrics.temperature,
relativeHumidity = environmentMetrics.relativeHumidity,
soilTemperature = environmentMetrics.soilTemperature,
soilMoisture = environmentMetrics.soilMoisture,
barometricPressure = environmentMetrics.barometricPressure,
gasResistance = environmentMetrics.gasResistance,
voltage = environmentMetrics.voltage,
current = environmentMetrics.current,
iaq = environmentMetrics.iaq,
EnvironmentMetrics.fromTelemetryProto(
environmentTelemetry.environmentMetrics,
environmentTelemetry.time,
),
hopsAway = hopsAway,
)

View file

@ -18,35 +18,50 @@
package com.geeksville.mesh.model
import androidx.compose.ui.graphics.Color
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
import com.geeksville.mesh.ui.common.theme.GraphColors.InfantryBlue
import com.geeksville.mesh.ui.common.theme.GraphColors.LightGreen
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
import com.geeksville.mesh.ui.common.theme.GraphColors.Orange
import com.geeksville.mesh.ui.common.theme.GraphColors.Pink
import com.geeksville.mesh.ui.common.theme.GraphColors.Purple
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
import com.geeksville.mesh.ui.common.theme.GraphColors.Yellow
import com.geeksville.mesh.util.UnitConversions
@Suppress("MagicNumber")
enum class Environment(val color: Color) {
TEMPERATURE(Red) {
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.temperature
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.temperature
},
HUMIDITY(InfantryBlue) {
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.relativeHumidity
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.relativeHumidity
},
SOIL_TEMPERATURE(Pink) {
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.soilTemperature
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.soilTemperature
},
SOIL_MOISTURE(Purple) {
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.soilMoisture.toFloat()
override fun getValue(telemetry: TelemetryProtos.Telemetry) =
telemetry.environmentMetrics.soilMoisture?.toFloat()
},
IAQ(Color.Green) {
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.iaq.toFloat()
BAROMETRIC_PRESSURE(Green) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.barometricPressure
},
BAROMETRIC_PRESSURE(Orange) {
override fun getValue(telemetry: Telemetry): Float = telemetry.environmentMetrics.barometricPressure
GAS_RESISTANCE(Yellow) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.gasResistance
},
IAQ(Magenta) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.iaq?.toFloat()
},
LUX(LightGreen) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.lux
},
UV_LUX(Orange) {
override fun getValue(telemetry: TelemetryProtos.Telemetry) = telemetry.environmentMetrics.uvLux
}, ;
abstract fun getValue(telemetry: Telemetry): Float
abstract fun getValue(telemetry: TelemetryProtos.Telemetry): Float?
}
/**
@ -57,14 +72,14 @@ enum class Environment(val color: Color) {
* @param times [Pair] with the oldest and newest times in that order
*/
data class EnvironmentGraphingData(
val metrics: List<Telemetry>,
val metrics: List<TelemetryProtos.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<Telemetry> = emptyList()) {
data class EnvironmentMetricsState(val environmentMetrics: List<TelemetryProtos.Telemetry> = emptyList()) {
fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty()
/**
@ -85,96 +100,96 @@ data class EnvironmentMetricsState(val environmentMetrics: List<Telemetry> = emp
/* Grab the combined min and max for temp, humidity, soil_Temperature, soilMoisture and iaq. */
val minValues = mutableListOf<Float>()
val maxValues = mutableListOf<Float>()
val (minTemp, maxTemp) =
Pair(
telemetries.minBy { it.environmentMetrics.temperature },
telemetries.maxBy { it.environmentMetrics.temperature },
)
var minTempValue = minTemp.environmentMetrics.temperature
var maxTempValue = maxTemp.environmentMetrics.temperature
if (useFahrenheit) {
minTempValue = UnitConversions.celsiusToFahrenheit(minTempValue)
maxTempValue = UnitConversions.celsiusToFahrenheit(maxTempValue)
}
if (minTemp.environmentMetrics.temperature != 0f || maxTemp.environmentMetrics.temperature != 0f) {
// Temperature
val temperatures = telemetries.mapNotNull { it.environmentMetrics.temperature?.takeIf { !it.isNaN() } }
if (temperatures.isNotEmpty()) {
var minTempValue = temperatures.minOf { it }
var maxTempValue = temperatures.maxOf { it }
if (useFahrenheit) {
minTempValue = UnitConversions.celsiusToFahrenheit(minTempValue)
maxTempValue = UnitConversions.celsiusToFahrenheit(maxTempValue)
}
minValues.add(minTempValue)
maxValues.add(maxTempValue)
shouldPlot[Environment.TEMPERATURE.ordinal] = true
}
val (minHumidity, maxHumidity) =
Pair(
telemetries.minBy { it.environmentMetrics.relativeHumidity },
telemetries.maxBy { it.environmentMetrics.relativeHumidity },
)
if (
minHumidity.environmentMetrics.relativeHumidity != 0f ||
maxHumidity.environmentMetrics.relativeHumidity != 0f
) {
minValues.add(minHumidity.environmentMetrics.relativeHumidity)
maxValues.add(maxHumidity.environmentMetrics.relativeHumidity)
// Relative Humidity
val humidities =
telemetries.mapNotNull { it.environmentMetrics.relativeHumidity?.takeIf { !it.isNaN() && it != 0.0f } }
if (humidities.isNotEmpty()) {
minValues.add(humidities.minOf { it })
maxValues.add(humidities.maxOf { it })
shouldPlot[Environment.HUMIDITY.ordinal] = true
}
var minSoilTemperatureValue = minTemp.environmentMetrics.soilTemperature
var maxSoilTemperatureValue = maxTemp.environmentMetrics.soilTemperature
if (useFahrenheit) {
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
}
if (minTemp.environmentMetrics.soilTemperature != 0f || maxTemp.environmentMetrics.soilTemperature != 0f) {
// Soil Temperature
val soilTemperatures = telemetries.mapNotNull { it.environmentMetrics.soilTemperature?.takeIf { !it.isNaN() } }
if (soilTemperatures.isNotEmpty()) {
var minSoilTemperatureValue = soilTemperatures.minOf { it }
var maxSoilTemperatureValue = soilTemperatures.maxOf { it }
if (useFahrenheit) {
minSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(minSoilTemperatureValue)
maxSoilTemperatureValue = UnitConversions.celsiusToFahrenheit(maxSoilTemperatureValue)
}
minValues.add(minSoilTemperatureValue)
maxValues.add(maxSoilTemperatureValue)
shouldPlot[Environment.SOIL_TEMPERATURE.ordinal] = true
}
val (minSoilMoisture, maxSoilMoisture) =
Pair(
telemetries.minBy { it.environmentMetrics.soilMoisture },
telemetries.maxBy { it.environmentMetrics.soilMoisture },
)
val soilMoistureRange = 0..100
if (
minSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange ||
maxSoilMoisture.environmentMetrics.soilMoisture in soilMoistureRange
) {
minValues.add(minSoilMoisture.environmentMetrics.soilMoisture.toFloat())
maxValues.add(maxSoilMoisture.environmentMetrics.soilMoisture.toFloat())
// Soil Moisture
val soilMoistures =
telemetries.mapNotNull { it.environmentMetrics.soilMoisture?.takeIf { it != Int.MIN_VALUE } }
if (soilMoistures.isNotEmpty()) {
minValues.add(soilMoistures.minOf { it.toFloat() })
maxValues.add(soilMoistures.maxOf { it.toFloat() })
shouldPlot[Environment.SOIL_MOISTURE.ordinal] = true
}
val (minIAQ, maxIAQ) =
Pair(telemetries.minBy { it.environmentMetrics.iaq }, telemetries.maxBy { it.environmentMetrics.iaq })
if (minIAQ.environmentMetrics.iaq != 0 || maxIAQ.environmentMetrics.iaq != 0) {
minValues.add(minIAQ.environmentMetrics.iaq.toFloat())
maxValues.add(maxIAQ.environmentMetrics.iaq.toFloat())
// IAQ
val iaqs = telemetries.mapNotNull { it.environmentMetrics.iaq?.takeIf { it != Int.MIN_VALUE } }
if (iaqs.isNotEmpty()) {
minValues.add(iaqs.minOf { it.toFloat() })
maxValues.add(iaqs.maxOf { it.toFloat() })
shouldPlot[Environment.IAQ.ordinal] = true
}
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
val max = if (maxValues.isEmpty()) 0f else maxValues.maxOf { it }
val (minPressure, maxPressure) =
Pair(
telemetries.minBy { it.environmentMetrics.barometricPressure },
telemetries.maxBy { it.environmentMetrics.barometricPressure },
)
if (
minPressure.environmentMetrics.barometricPressure != 0.0F &&
maxPressure.environmentMetrics.barometricPressure != 0.0F
) {
// Barometric Pressure
val pressures = telemetries.mapNotNull { it.environmentMetrics.barometricPressure?.takeIf { !it.isNaN() } }
var minPressureValue = 0f
var maxPressureValue = 0f
if (pressures.isNotEmpty()) {
minPressureValue = pressures.minOf { it }
maxPressureValue = pressures.maxOf { it }
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] = true
}
// Lux
val luxValues = telemetries.mapNotNull { it.environmentMetrics.lux?.takeIf { !it.isNaN() } }
if (luxValues.isNotEmpty()) {
minValues.add(luxValues.minOf { it })
maxValues.add(luxValues.maxOf { it })
shouldPlot[Environment.LUX.ordinal] = true
}
// UVLux
val uvLuxValues = telemetries.mapNotNull { it.environmentMetrics.uvLux?.takeIf { !it.isNaN() } }
if (uvLuxValues.isNotEmpty()) {
minValues.add(uvLuxValues.minOf { it })
maxValues.add(uvLuxValues.maxOf { it })
shouldPlot[Environment.UV_LUX.ordinal] = true
}
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 })
return EnvironmentGraphingData(
metrics = telemetries,
shouldPlot = shouldPlot.toList(),
leftMinMax =
Pair(
minPressure.environmentMetrics.barometricPressure,
maxPressure.environmentMetrics.barometricPressure,
),
leftMinMax = Pair(minPressureValue, maxPressureValue),
rightMinMax = Pair(min, max),
times = Pair(oldest.time, newest.time),
)

View file

@ -297,7 +297,8 @@ constructor(
environmentMetrics =
telemetry.filter {
it.hasEnvironmentMetrics() &&
it.environmentMetrics.relativeHumidity >= 0f &&
it.environmentMetrics.hasRelativeHumidity() &&
it.environmentMetrics.hasTemperature() &&
!it.environmentMetrics.temperature.isNaN()
},
)

View file

@ -79,7 +79,8 @@ enum class Iaq(val color: Color, val description: String, val range: IntRange) {
DangerouslyPolluted(IAQDangerouslyPolluted, "Dangerously Polluted", 501..Int.MAX_VALUE),
}
fun getIaq(iaq: Int): Iaq = when {
fun getIaq(iaq: Int): Iaq? = when {
iaq == Int.MIN_VALUE -> null
iaq in Iaq.Excellent.range -> Iaq.Excellent
iaq in Iaq.Good.range -> Iaq.Good
iaq in Iaq.LightlyPolluted.range -> Iaq.LightlyPolluted
@ -106,88 +107,96 @@ enum class IaqDisplayMode {
@Suppress("LongMethod", "UnusedPrivateProperty")
@Composable
fun IndoorAirQuality(iaq: Int, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
fun IndoorAirQuality(iaq: Int?, displayMode: IaqDisplayMode = IaqDisplayMode.Pill) {
if (iaq == null || iaq == Int.MIN_VALUE) {
return
}
var isLegendOpen by remember { mutableStateOf(false) }
val iaqEnum = getIaq(iaq)
val iaqEnum = if (iaq != null) getIaq(iaq) else null
val gradient = Brush.linearGradient(colors = Iaq.entries.map { it.color })
Column {
when (displayMode) {
IaqDisplayMode.Pill -> {
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(iaqEnum.color)
.width(125.dp)
.height(30.dp)
.clickable { isLegendOpen = true },
) {
Row(
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically,
if (iaqEnum != null) {
Column {
when (displayMode) {
IaqDisplayMode.Pill -> {
Box(
modifier =
Modifier.clip(RoundedCornerShape(10.dp))
.background(iaqEnum.color)
.width(125.dp)
.height(30.dp)
.clickable { isLegendOpen = true },
) {
Text(text = "IAQ $iaq", color = Color.White, fontWeight = FontWeight.Bold)
Icon(
imageVector = if (iaq < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
contentDescription = stringResource(R.string.air_quality_icon),
tint = Color.White,
)
Row(
modifier = Modifier.padding(4.dp).align(Alignment.CenterStart),
verticalAlignment = Alignment.CenterVertically,
) {
Text(text = "IAQ $iaq", color = Color.White, fontWeight = FontWeight.Bold)
Icon(
imageVector =
if (iaqEnum.range.first < 100) Icons.Default.ThumbUp else Icons.Filled.Warning,
contentDescription = stringResource(R.string.air_quality_icon),
tint = Color.White,
)
}
}
}
}
IaqDisplayMode.Dot -> {
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$iaq")
Spacer(modifier = Modifier.width(4.dp))
Box(modifier = Modifier.size(10.dp).background(iaqEnum.color, shape = CircleShape))
IaqDisplayMode.Dot -> {
Column(modifier = Modifier.clickable { isLegendOpen = true }) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(text = "$iaq")
Spacer(modifier = Modifier.width(4.dp))
Box(modifier = Modifier.size(10.dp).background(iaqEnum.color, shape = CircleShape))
}
}
}
}
IaqDisplayMode.Text -> {
Text(
text = getIaqDescriptionWithRange(iaqEnum),
fontSize = 12.sp,
modifier = Modifier.clickable { isLegendOpen = true },
)
}
IaqDisplayMode.Text -> {
Text(
text = getIaqDescriptionWithRange(iaqEnum),
fontSize = 12.sp,
modifier = Modifier.clickable { isLegendOpen = true },
)
}
IaqDisplayMode.Gauge -> {
CircularProgressIndicator(
progress = iaq / 500f,
modifier = Modifier.size(60.dp).clickable { isLegendOpen = true },
strokeWidth = 8.dp,
color = iaqEnum.color,
)
Text(text = "$iaq")
}
IaqDisplayMode.Gradient -> {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.clickable { isLegendOpen = true },
) {
LinearProgressIndicator(
IaqDisplayMode.Gauge -> {
CircularProgressIndicator(
progress = iaq / 500f,
modifier = Modifier.fillMaxWidth().height(20.dp),
modifier = Modifier.size(60.dp).clickable { isLegendOpen = true },
strokeWidth = 8.dp,
color = iaqEnum.color,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = iaqEnum.description, fontSize = 12.sp)
Text(text = "${iaqEnum.description}")
}
IaqDisplayMode.Gradient -> {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.clickable { isLegendOpen = true },
) {
LinearProgressIndicator(
progress = iaq / 500f,
modifier = Modifier.fillMaxWidth().height(20.dp),
color = iaqEnum.color,
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = iaqEnum.description, fontSize = 12.sp)
}
}
}
}
if (isLegendOpen) {
AlertDialog(
onDismissRequest = { isLegendOpen = false },
shape = RoundedCornerShape(16.dp),
text = { IAQScale() },
confirmButton = {
TextButton(onClick = { isLegendOpen = false }) { Text(text = stringResource(id = R.string.close)) }
},
)
if (isLegendOpen) {
AlertDialog(
onDismissRequest = { isLegendOpen = false },
shape = RoundedCornerShape(16.dp),
text = { IAQScale() },
confirmButton = {
TextButton(onClick = { isLegendOpen = false }) {
Text(text = stringResource(id = R.string.close))
}
},
)
}
}
}
}

View file

@ -40,6 +40,7 @@ object IAQColors {
object GraphColors {
val InfantryBlue = Color(red = 75, green = 119, blue = 190)
val LightGreen = Color(0xFF4BF0BE)
val Purple = Color(0xFF9C27B0)
val Pink = Color(red = 255, green = 102, blue = 204)
val Orange = Color(0xFFFF8800)

View file

@ -71,6 +71,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.size
import androidx.compose.ui.unit.sp
import androidx.datastore.core.IOException
import androidx.hilt.navigation.compose.hiltViewModel

View file

@ -57,6 +57,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.geeksville.mesh.R
import com.geeksville.mesh.model.Environment
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_MINUTE_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
@ -78,7 +79,12 @@ private const val DATE_Y = 32f
private const val LINE_LIMIT = 4
private const val TEXT_PAINT_ALPHA = 192
data class LegendData(val nameRes: Int, val color: Color, val isLine: Boolean = false)
data class LegendData(
val nameRes: Int,
val color: Color,
val isLine: Boolean = false,
val environmentMetric: Environment? = null,
)
@Composable
fun ChartHeader(amount: Int) {

View file

@ -71,7 +71,7 @@ import com.geeksville.mesh.ui.common.theme.AppTheme
import com.geeksville.mesh.ui.common.theme.GraphColors.Cyan
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
import com.geeksville.mesh.ui.common.theme.GraphColors.Orange
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MAX_PERCENT_VALUE
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
@ -79,21 +79,39 @@ import com.geeksville.mesh.util.GraphUtil
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.plotPoint
private enum class Device(val color: Color) {
BATTERY(Green),
CH_UTIL(Magenta),
AIR_UTIL(Cyan),
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT)
private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIGHT + Y_AXIS_WEIGHT)
private enum class Device(val color: Color) {
BATTERY(Green) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.batteryLevel.toFloat()
},
CH_UTIL(Magenta) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.channelUtilization
},
AIR_UTIL(Cyan) {
override fun getValue(telemetry: Telemetry): Float = telemetry.deviceMetrics.airUtilTx
}, ;
abstract fun getValue(telemetry: Telemetry): Float
}
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true),
LegendData(nameRes = R.string.channel_utilization, color = Device.CH_UTIL.color),
LegendData(nameRes = R.string.air_utilization, color = Device.AIR_UTIL.color),
LegendData(nameRes = R.string.battery, color = Device.BATTERY.color, isLine = true, environmentMetric = null),
LegendData(
nameRes = R.string.channel_utilization,
color = Device.CH_UTIL.color,
isLine = false,
environmentMetric = null,
),
LegendData(
nameRes = R.string.air_utilization,
color = Device.AIR_UTIL.color,
isLine = false,
environmentMetric = null,
),
)
@Composable
@ -117,7 +135,7 @@ fun DeviceMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
DeviceMetricsChart(
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
data.reversed(),
telemetries = data.reversed(),
selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
@ -186,7 +204,7 @@ private fun DeviceMetricsChart(
*/
HorizontalLinesOverlay(
modifier.width(dp),
lineColors = listOf(graphColor, Orange, Color.Red, graphColor, graphColor),
lineColors = listOf(graphColor, Color.Yellow, Color.Red, graphColor, graphColor),
)
TimeAxisOverlay(modifier.width(dp), oldest = oldest.time, newest = newest.time, selectedTime.lineInterval())

View file

@ -0,0 +1,350 @@
/*
* Copyright (c) 2025 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
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* 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 com.geeksville.mesh.ui.metrics
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.model.Environment
import com.geeksville.mesh.model.EnvironmentGraphingData
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
@Suppress("MagicNumber")
private val LEGEND_DATA_1 =
listOf(
LegendData(
nameRes = R.string.temperature,
color = Environment.TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.TEMPERATURE,
),
LegendData(
nameRes = R.string.humidity,
color = Environment.HUMIDITY.color,
isLine = true,
environmentMetric = Environment.HUMIDITY,
),
)
private val LEGEND_DATA_2 =
listOf(
LegendData(
nameRes = R.string.iaq,
color = Environment.IAQ.color,
isLine = true,
environmentMetric = Environment.IAQ,
),
LegendData(
nameRes = R.string.baro_pressure,
color = Environment.BAROMETRIC_PRESSURE.color,
isLine = true,
environmentMetric = Environment.BAROMETRIC_PRESSURE,
),
LegendData(
nameRes = R.string.lux,
color = Environment.LUX.color,
isLine = true,
environmentMetric = Environment.LUX,
),
LegendData(
nameRes = R.string.uv_lux,
color = Environment.UV_LUX.color,
isLine = true,
environmentMetric = Environment.UV_LUX,
),
)
private val LEGEND_DATA_3 =
listOf(
LegendData(
nameRes = R.string.soil_temperature,
color = Environment.SOIL_TEMPERATURE.color,
isLine = true,
environmentMetric = Environment.SOIL_TEMPERATURE,
),
LegendData(
nameRes = R.string.soil_moisture,
color = Environment.SOIL_MOISTURE.color,
isLine = true,
environmentMetric = Environment.SOIL_MOISTURE,
),
)
@Composable
fun EnvironmentMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) {
return
}
val (oldest, newest) = graphData.times
val timeDiff = newest - oldest
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
val shouldPlot = graphData.shouldPlot
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
val visibleWidthPx = screenWidth * chartWidthRatio
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Row(modifier = modifier.fillMaxWidth().fillMaxHeight()) {
BarometricPressureYAxisLabel(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
shouldPlotBarometricPressure = shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal],
minValue = graphData.leftMinMax.first,
maxValue = graphData.leftMinMax.second,
)
ChartContent(
modifier = Modifier.weight(CHART_WEIGHT).fillMaxHeight(),
scrollState = scrollState,
dp = dp,
oldest = oldest,
newest = newest,
selectedTime = selectedTime,
telemetries = telemetries,
graphData = graphData,
rightMin = graphData.rightMinMax.first,
rightMax = graphData.rightMinMax.second,
timeDiff = timeDiff,
)
YAxisLabels(
modifier = Modifier.weight(Y_AXIS_WEIGHT).fillMaxHeight(),
MaterialTheme.colorScheme.onSurface,
minValue = graphData.rightMinMax.first,
maxValue = graphData.rightMinMax.second,
)
}
Spacer(modifier = Modifier.height(16.dp))
MetricLegends(graphData = graphData, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("detekt:LongMethod")
@Composable
private fun MetricPlottingCanvas(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
oldest: Int,
timeDiff: Int,
rightMin: Float,
rightMax: Float,
) {
val (pressureMin, pressureMax) = graphData.leftMinMax
val shouldPlot = graphData.shouldPlot
val graphColor = MaterialTheme.colorScheme.onSurface
Canvas(modifier = modifier) {
val height = size.height
val width = size.width
var min: Float
var diff: Float
var index: Int
var first: Int
for (metric in Environment.entries) {
if (!shouldPlot[metric.ordinal]) {
continue
}
if (metric == Environment.BAROMETRIC_PRESSURE) {
diff = pressureMax - pressureMin
min = pressureMin
} else { // Reset for other metrics to use rightMin/rightMax
min = rightMin
diff = rightMax - rightMin
}
index = 0
while (index < telemetries.size) {
first = index
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val rawValue = metric.getValue(telemetry) // This is Float?
// Default to 0f if the actual value is null or NaN. This is a reasonable default for
// lux.
val pointValue =
if (rawValue != null && !rawValue.isNaN()) {
rawValue
} else {
0f
}
// Use 'min' and 'diff' from the outer scope, which are specific to the current metric's
// scale group.
val currentMin = min
// Avoid division by zero if all values in the current y-axis range are the same.
val currentDiff = if (diff == 0f) 1f else diff
val ratio = (pointValue - currentMin) / currentDiff
var y = height - (ratio * height)
// Final check to ensure y is a valid, plottable coordinate.
if (y.isNaN() || y.isInfinite()) {
y = height // Default to the bottom of the chart if calculation still results in an
// invalid number.
} else {
y = y.coerceIn(0f, height) // Clamp to chart bounds to be safe.
}
return@createPath y
}
drawPathWithGradient(
path = path,
color = metric.color,
height = height,
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
)
}
}
}
}
@Composable
private fun BarometricPressureYAxisLabel(
modifier: Modifier,
shouldPlotBarometricPressure: Boolean,
minValue: Float,
maxValue: Float,
) {
if (shouldPlotBarometricPressure) {
YAxisLabels(
modifier = modifier,
Environment.BAROMETRIC_PRESSURE.color,
minValue = minValue,
maxValue = maxValue,
)
}
}
@Composable
private fun ChartContent(
modifier: Modifier = Modifier,
scrollState: ScrollState,
dp: Dp,
oldest: Int,
newest: Int,
selectedTime: TimeFrame,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
rightMin: Float,
rightMax: Float,
timeDiff: Int,
) {
val graphColor = MaterialTheme.colorScheme.onSurface
Box(
contentAlignment = Alignment.TopStart,
modifier = modifier.horizontalScroll(state = scrollState, reverseScrolling = true),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
TimeAxisOverlay(modifier = modifier.width(dp), oldest = oldest, newest = newest, selectedTime.lineInterval())
MetricPlottingCanvas(
modifier = modifier.width(dp),
telemetries = telemetries,
graphData = graphData,
selectedTime = selectedTime,
oldest = oldest,
timeDiff = timeDiff,
rightMin = rightMin,
rightMax = rightMax,
)
}
}
@Composable
private fun MetricLegends(graphData: EnvironmentGraphingData, promptInfoDialog: () -> Unit) {
Legend(LEGEND_DATA_1.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
Legend(LEGEND_DATA_3.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] }, displayInfoIcon = false)
Legend(
LEGEND_DATA_2.filter { graphData.shouldPlot[it.environmentMetric?.ordinal ?: 0] },
promptInfoDialog = promptInfoDialog,
)
}
// private const val LINE_ON = 10f
// private const val LINE_OFF = 20f
// private val TIME_FORMAT: DateFormat = DateFormat.getTimeInstance(DateFormat.MEDIUM)
// private val DATE_FORMAT: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT)
// private const val DATE_Y = 32f
// private const val LINE_LIMIT = 4
// private const val TEXT_PAINT_ALPHA = 192

View file

@ -17,11 +17,7 @@
package com.geeksville.mesh.ui.metrics
import android.annotation.SuppressLint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@ -33,7 +29,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
@ -47,72 +42,27 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.EnvironmentMetrics
import com.geeksville.mesh.R
import com.geeksville.mesh.TelemetryProtos
import com.geeksville.mesh.TelemetryProtos.Telemetry
import com.geeksville.mesh.copy
import com.geeksville.mesh.model.Environment
import com.geeksville.mesh.model.EnvironmentGraphingData
import com.geeksville.mesh.model.MetricsViewModel
import com.geeksville.mesh.model.TimeFrame
import com.geeksville.mesh.ui.common.components.IaqDisplayMode
import com.geeksville.mesh.ui.common.components.IndoorAirQuality
import com.geeksville.mesh.ui.common.components.OptionLabel
import com.geeksville.mesh.ui.common.components.SlidingSelector
import com.geeksville.mesh.ui.common.theme.GraphColors.Blue
import com.geeksville.mesh.ui.common.theme.GraphColors.Green
import com.geeksville.mesh.ui.common.theme.GraphColors.Magenta
import com.geeksville.mesh.ui.common.theme.GraphColors.Pink
import com.geeksville.mesh.ui.common.theme.GraphColors.Purple
import com.geeksville.mesh.ui.common.theme.GraphColors.Red
import com.geeksville.mesh.ui.common.theme.GraphColors.Yellow
import com.geeksville.mesh.ui.metrics.CommonCharts.DATE_TIME_FORMAT
import com.geeksville.mesh.ui.metrics.CommonCharts.MS_PER_SEC
import com.geeksville.mesh.util.GraphUtil.createPath
import com.geeksville.mesh.util.GraphUtil.drawPathWithGradient
import com.geeksville.mesh.util.UnitConversions.celsiusToFahrenheit
@Suppress("MagicNumber")
private enum class Environment(val color: Color) {
TEMPERATURE(Red),
RELATIVE_HUMIDITY(Blue),
SOIL_TEMPERATURE(Pink),
SOIL_MOISTURE(Purple),
BAROMETRIC_PRESSURE(Green),
GAS_RESISTANCE(Yellow),
IAQ(Magenta),
}
private const val CHART_WEIGHT = 1f
private const val Y_AXIS_WEIGHT = 0.1f
// EnvironmentMetrics can have 1 or 2 Y-axis labels depending on whether barometric pressure is plotted
// We'll calculate this dynamically in the chart function
private val LEGEND_DATA_1 =
listOf(
LegendData(nameRes = R.string.temperature, color = Environment.TEMPERATURE.color, isLine = true),
LegendData(nameRes = R.string.humidity, color = Environment.HUMIDITY.color, isLine = true),
)
private val LEGEND_DATA_2 =
listOf(
LegendData(nameRes = R.string.iaq, color = Environment.IAQ.color, isLine = true),
LegendData(nameRes = R.string.baro_pressure, color = Environment.BAROMETRIC_PRESSURE.color, isLine = true),
)
private val LEGEND_DATA_3 =
listOf(
LegendData(nameRes = R.string.soil_temperature, color = Environment.SOIL_TEMPERATURE.color, isLine = true),
LegendData(nameRes = R.string.soil_moisture, color = Environment.SOIL_MOISTURE.color, isLine = true),
)
@Composable
fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
@ -151,7 +101,7 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
modifier = Modifier.fillMaxWidth().fillMaxHeight(fraction = 0.33f),
telemetries = processedTelemetries.reversed(),
graphData = graphData,
selectedTimeFrame,
selectedTime = selectedTimeFrame,
promptInfoDialog = { displayInfoDialog = true },
)
@ -169,230 +119,204 @@ fun EnvironmentMetricsScreen(viewModel: MetricsViewModel = hiltViewModel()) {
}
}
@SuppressLint("ConfigurationScreenWidthHeight")
@Suppress("LongMethod")
@Composable
private fun EnvironmentMetricsChart(
modifier: Modifier = Modifier,
telemetries: List<Telemetry>,
graphData: EnvironmentGraphingData,
selectedTime: TimeFrame,
promptInfoDialog: () -> Unit,
) {
ChartHeader(amount = telemetries.size)
if (telemetries.isEmpty()) {
return
private fun TemperatureDisplay(temperature: Float, environmentDisplayFahrenheit: Boolean) {
if (!temperature.isNaN()) {
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
Text(
text = textFormat.format(stringResource(id = R.string.temperature), temperature),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
val (oldest, newest) = graphData.times
val timeDiff = newest - oldest
val scrollState = rememberScrollState()
val screenWidth = LocalWindowInfo.current.containerSize.width
val dp by remember(key1 = selectedTime) { mutableStateOf(selectedTime.dp(screenWidth, time = timeDiff.toLong())) }
val shouldPlot = graphData.shouldPlot
// Calculate visible time range based on scroll position and chart width
val visibleTimeRange = run {
val totalWidthPx = with(LocalDensity.current) { dp.toPx() }
val scrollPx = scrollState.value.toFloat()
// Calculate chart width ratio dynamically based on whether barometric pressure is plotted
val yAxisCount = if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) 2 else 1
val chartWidthRatio = CHART_WEIGHT / (CHART_WEIGHT + (Y_AXIS_WEIGHT * yAxisCount))
val visibleWidthPx = screenWidth * chartWidthRatio
val leftRatio = (scrollPx / totalWidthPx).coerceIn(0f, 1f)
val rightRatio = ((scrollPx + visibleWidthPx) / totalWidthPx).coerceIn(0f, 1f)
// With reverseScrolling = true, scrolling right shows older data (left side of chart)
val visibleOldest = oldest + (timeDiff * (1f - rightRatio)).toInt()
val visibleNewest = oldest + (timeDiff * (1f - leftRatio)).toInt()
visibleOldest to visibleNewest
}
TimeLabels(oldest = visibleTimeRange.first, newest = visibleTimeRange.second)
Spacer(modifier = Modifier.height(16.dp))
val graphColor = MaterialTheme.colorScheme.onSurface
val (rightMin, rightMax) = graphData.rightMinMax
val (pressureMin, pressureMax) = graphData.leftMinMax
var min = rightMin
var diff = rightMax - rightMin
Row {
if (shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal]) {
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
Environment.BAROMETRIC_PRESSURE.color,
minValue = pressureMin,
maxValue = pressureMax,
)
@Composable
private fun HumidityAndBarometricPressureDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
envMetrics.relativeHumidity?.let { humidity ->
if (!humidity.isNaN()) {
Text(
text = "%s %.2f%%".format(stringResource(id = R.string.humidity), humidity),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
Box(
contentAlignment = Alignment.TopStart,
modifier = Modifier.horizontalScroll(state = scrollState, reverseScrolling = true).weight(weight = 1f),
) {
HorizontalLinesOverlay(modifier.width(dp), lineColors = List(size = 5) { graphColor })
envMetrics.barometricPressure?.let { pressure ->
if (!pressure.isNaN() && pressure > 0) { // Keep pressure > 0 check
Text(
text = "%.2f hPa".format(pressure),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
TimeAxisOverlay(
modifier = modifier.width(dp),
oldest = oldest,
newest = newest,
selectedTime.lineInterval(),
)
Canvas(modifier = modifier.width(dp)) {
val height = size.height
val width = size.width
var index: Int
var first: Int
for (metric in Environment.entries) {
if (!shouldPlot[metric.ordinal]) {
continue
}
if (metric == Environment.BAROMETRIC_PRESSURE) {
diff = pressureMax - pressureMin
min = pressureMin
}
index = 0
while (index < telemetries.size) {
first = index
val path = Path()
index =
createPath(
telemetries = telemetries,
index = index,
path = path,
oldestTime = oldest,
timeRange = timeDiff,
width = width,
timeThreshold = selectedTime.timeThreshold(),
) { i ->
val telemetry = telemetries.getOrNull(i) ?: telemetries.last()
val ratio = (metric.getValue(telemetry) - min) / diff
val y = height - (ratio * height)
return@createPath y
}
drawPathWithGradient(
path = path,
color = metric.color,
height = height,
x1 = ((telemetries[index - 1].time - oldest).toFloat() / timeDiff) * width,
x2 = ((telemetries[first].time - oldest).toFloat() / timeDiff) * width,
)
}
@Composable
private fun SoilMetricsDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics, environmentDisplayFahrenheit: Boolean) {
if (
envMetrics.soilTemperature != null ||
(envMetrics.soilMoisture != null && envMetrics.soilMoisture != Int.MIN_VALUE)
) {
Spacer(modifier = Modifier.height(4.dp))
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 ->
if (soilMoistureValue != Int.MIN_VALUE) {
Text(
text = soilMoistureTextFormat.format(stringResource(R.string.soil_moisture), soilMoistureValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
envMetrics.soilTemperature?.let { soilTemperature ->
if (!soilTemperature.isNaN()) {
Text(
text =
soilTemperatureTextFormat.format(
stringResource(R.string.soil_temperature),
soilTemperature,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
YAxisLabels(
modifier = modifier.weight(weight = Y_AXIS_WEIGHT),
graphColor,
minValue = rightMin,
maxValue = rightMax,
)
}
Spacer(modifier = Modifier.height(16.dp))
Legend(LEGEND_DATA_1, displayInfoIcon = false)
Legend(LEGEND_DATA_3, displayInfoIcon = false)
Legend(LEGEND_DATA_2, promptInfoDialog = promptInfoDialog)
Spacer(modifier = Modifier.height(16.dp))
}
@Suppress("LongMethod", "MagicNumber")
@Composable
private fun IaqDisplay(iaqValue: Int) {
if (iaqValue != Int.MIN_VALUE) {
Spacer(modifier = Modifier.height(4.dp))
/* Air Quality */
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.iaq),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Spacer(modifier = Modifier.width(4.dp))
IndoorAirQuality(iaq = iaqValue, displayMode = IaqDisplayMode.Dot)
}
}
}
@Composable
private fun LuxUVLuxDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
envMetrics.lux?.let { luxValue ->
if (!luxValue.isNaN()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.0f lx".format(stringResource(R.string.lux), luxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
envMetrics.uvLux?.let { uvLuxValue ->
if (!uvLuxValue.isNaN()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.0f UVlx".format(stringResource(R.string.uv_lux), uvLuxValue),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Composable
private fun VoltageCurrentDisplay(envMetrics: TelemetryProtos.EnvironmentMetrics) {
envMetrics.voltage?.let { voltage ->
if (!voltage.isNaN()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.2f V".format(stringResource(R.string.voltage), voltage),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
envMetrics.current?.let { current ->
if (!current.isNaN()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.2f A".format(stringResource(R.string.current), current),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
}
@Composable
private fun GasResistanceDisplay(gasResistance: Float) {
if (!gasResistance.isNaN()) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "%s %.2f Ohm".format(stringResource(R.string.gas_resistance), gasResistance),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
}
@Composable
private fun EnvironmentMetricsCard(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp)) {
Surface {
SelectionContainer {
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
val textFormat = if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
Text(
text = textFormat.format(stringResource(id = R.string.temperature), envMetrics.temperature),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
Spacer(modifier = Modifier.height(4.dp))
/* Humidity and Barometric Pressure */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text =
"%s %.2f%%".format(stringResource(id = R.string.humidity), envMetrics.relativeHumidity),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
if (envMetrics.barometricPressure > 0) {
Text(
text = "%.2f hPa".format(envMetrics.barometricPressure),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
/* Soil Moisture and Soil Temperature */
val soilMoistureRange = 0..100
if (
telemetry.environmentMetrics.hasSoilTemperature() ||
telemetry.environmentMetrics.soilMoisture in soilMoistureRange
) {
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
val soilTemperatureTextFormat =
if (environmentDisplayFahrenheit) "%s %.1f°F" else "%s %.1f°C"
val soilMoistureTextFormat = "%s %d%%"
Text(
text =
soilMoistureTextFormat.format(
stringResource(R.string.soil_moisture),
envMetrics.soilMoisture,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Text(
text =
soilTemperatureTextFormat.format(
stringResource(R.string.soil_temperature),
envMetrics.soilTemperature,
),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
}
}
if (telemetry.environmentMetrics.hasIaq()) {
Spacer(modifier = Modifier.height(4.dp))
/* Air Quality */
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
text = stringResource(R.string.iaq),
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
Spacer(modifier = Modifier.width(4.dp))
IndoorAirQuality(iaq = telemetry.environmentMetrics.iaq, displayMode = IaqDisplayMode.Dot)
}
}
}
}
}
Surface { SelectionContainer { EnvironmentMetricsContent(telemetry, environmentDisplayFahrenheit) } }
}
}
@Composable
private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFahrenheit: Boolean) {
val envMetrics = telemetry.environmentMetrics
val time = telemetry.time * MS_PER_SEC
Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
/* Time and Temperature */
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = DATE_TIME_FORMAT.format(time),
style = TextStyle(fontWeight = FontWeight.Bold),
fontSize = MaterialTheme.typography.labelLarge.fontSize,
)
envMetrics.temperature?.let { temperature -> TemperatureDisplay(temperature, environmentDisplayFahrenheit) }
}
Spacer(modifier = Modifier.height(4.dp))
/* Humidity and Barometric Pressure */
HumidityAndBarometricPressureDisplay(envMetrics)
/* Soil Moisture and Soil Temperature */
SoilMetricsDisplay(envMetrics, environmentDisplayFahrenheit)
envMetrics.iaq?.let { iaqValue -> IaqDisplay(iaqValue) }
LuxUVLuxDisplay(envMetrics)
VoltageCurrentDisplay(envMetrics)
envMetrics.gasResistance?.let { gasResistance -> GasResistanceDisplay(gasResistance) }
}
}

View file

@ -184,9 +184,9 @@ fun PaxMetricsScreen(metricsViewModel: MetricsViewModel = hiltViewModel()) {
val minValue = 0f
val legendData =
listOf(
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color),
LegendData(PaxSeries.PAX.legendRes, PaxSeries.PAX.color, environmentMetric = null),
LegendData(PaxSeries.BLE.legendRes, PaxSeries.BLE.color, environmentMetric = null),
LegendData(PaxSeries.WIFI.legendRes, PaxSeries.WIFI.color, environmentMetric = null),
)
Column(modifier = Modifier.fillMaxSize()) {

View file

@ -112,8 +112,8 @@ fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair<Float, Float> {
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true),
LegendData(nameRes = R.string.voltage, color = VOLTAGE_COLOR, isLine = true),
LegendData(nameRes = R.string.current, color = Power.CURRENT.color, isLine = true, environmentMetric = null),
LegendData(nameRes = R.string.voltage, color = VOLTAGE_COLOR, isLine = true, environmentMetric = null),
)
@Composable

View file

@ -84,8 +84,8 @@ private const val CHART_WIDTH_RATIO = CHART_WEIGHT / (CHART_WEIGHT + Y_AXIS_WEIG
private val LEGEND_DATA =
listOf(
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color),
LegendData(nameRes = R.string.snr, color = Metric.SNR.color),
LegendData(nameRes = R.string.rssi, color = Metric.RSSI.color, environmentMetric = null),
LegendData(nameRes = R.string.snr, color = Metric.SNR.color, environmentMetric = null),
)
@Composable