diff --git a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt index 73742379a..9e1e9fccb 100644 --- a/app/src/main/java/com/geeksville/mesh/NodeInfo.kt +++ b/app/src/main/java/com/geeksville/mesh/NodeInfo.kt @@ -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, + ) } } diff --git a/app/src/main/java/com/geeksville/mesh/database/Converters.kt b/app/src/main/java/com/geeksville/mesh/database/Converters.kt index 5099b2cdb..71b39d29c 100644 --- a/app/src/main/java/com/geeksville/mesh/database/Converters.kt +++ b/app/src/main/java/com/geeksville/mesh/database/Converters.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt index 05753c5f2..12bad99ee 100644 --- a/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/database/MeshLogRepository.kt @@ -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() } 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 3167c2b13..565cd075e 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 @@ -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, ) diff --git a/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt b/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt index d0e6e1a55..6d9ba13be 100644 --- a/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt +++ b/app/src/main/java/com/geeksville/mesh/model/EnvironmentMetricsState.kt @@ -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, + val metrics: List, val shouldPlot: List, val leftMinMax: Pair = Pair(0f, 0f), val rightMinMax: Pair = Pair(0f, 0f), val times: Pair = Pair(0, 0), ) -data class EnvironmentMetricsState(val environmentMetrics: List = emptyList()) { +data class EnvironmentMetricsState(val environmentMetrics: List = emptyList()) { fun hasEnvironmentMetrics() = environmentMetrics.isNotEmpty() /** @@ -85,96 +100,96 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp /* Grab the combined min and max for temp, humidity, soil_Temperature, soilMoisture and iaq. */ val minValues = mutableListOf() val maxValues = mutableListOf() - 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), ) diff --git a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt index b0ad89f5e..eef265100 100644 --- a/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/model/MetricsViewModel.kt @@ -297,7 +297,8 @@ constructor( environmentMetrics = telemetry.filter { it.hasEnvironmentMetrics() && - it.environmentMetrics.relativeHumidity >= 0f && + it.environmentMetrics.hasRelativeHumidity() && + it.environmentMetrics.hasTemperature() && !it.environmentMetrics.temperature.isNaN() }, ) diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/components/IndoorAirQuality.kt b/app/src/main/java/com/geeksville/mesh/ui/common/components/IndoorAirQuality.kt index 8466e185b..c35be8dc9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/components/IndoorAirQuality.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/components/IndoorAirQuality.kt @@ -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)) + } + }, + ) + } } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/common/theme/CustomColors.kt b/app/src/main/java/com/geeksville/mesh/ui/common/theme/CustomColors.kt index 3de9ee80c..09157340a 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/common/theme/CustomColors.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/common/theme/CustomColors.kt @@ -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) diff --git a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt index 2ae7cc0de..62ecd8d99 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/debug/Debug.kt @@ -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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/CommonCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/CommonCharts.kt index 2d4404064..ab4e03420 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/CommonCharts.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/CommonCharts.kt @@ -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) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt index 0508bce55..767e6d008 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/DeviceMetrics.kt @@ -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()) diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt new file mode 100644 index 000000000..05b5aa4e5 --- /dev/null +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentCharts.kt @@ -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 . + */ + +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, + 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, + 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, + 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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt index 60019edc6..3cd153d32 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/EnvironmentMetrics.kt @@ -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, - 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) } } } diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt index 84cc5ca34..dfbf474e5 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/PaxMetrics.kt @@ -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()) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt index a6f4ad5ee..9704d7dcc 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/PowerMetrics.kt @@ -112,8 +112,8 @@ fun minMaxGraphVoltage(valueMin: Float, valueMax: Float): Pair { 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 diff --git a/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt b/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt index da0e51576..7c2a71b99 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/metrics/SignalMetrics.kt @@ -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