From f48fc61729b3f6f465c2a3bfd47d21114a9bd2bb Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 14 Apr 2026 19:03:24 -0500
Subject: [PATCH] feat(environment): add 1-Wire multi-thermometer (DS18B20)
display support (#5130)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.../composeResources/values/strings.xml | 1 +
.../meshtastic/core/ui/theme/CustomColors.kt | 4 ++
.../node/component/EnvironmentMetrics.kt | 13 ++++
.../feature/node/metrics/CommonCharts.kt | 7 +-
.../feature/node/metrics/EnvironmentCharts.kt | 26 ++++++-
.../node/metrics/EnvironmentMetrics.kt | 35 ++++++++++
.../node/metrics/EnvironmentMetricsState.kt | 69 ++++++++++++++++++-
.../feature/node/metrics/MetricsViewModel.kt | 14 +++-
8 files changed, 162 insertions(+), 7 deletions(-)
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index 4a5e40ade..9678c9919 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -748,6 +748,7 @@
Rain (24h)
Weight
Radiation
+ 1-Wire Temp
Indoor Air Quality (IAQ)
URL
diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
index 240c01503..d2047b603 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/theme/CustomColors.kt
@@ -60,6 +60,10 @@ object GraphColors {
val Lime = Color(0xFFCDDC39)
val Indigo = Color(0xFF3F51B5)
val DeepOrange = Color(0xFFFF5722)
+ val Magenta = Color(0xFFE040FB)
+ val SkyBlue = Color(0xFF03A9F4)
+ val Chartreuse = Color(0xFF76FF03)
+ val Coral = Color(0xFFFF6E40)
}
object StatusColors {
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
index aa44a6b7e..067d9cf40 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/EnvironmentMetrics.kt
@@ -40,6 +40,7 @@ import org.meshtastic.core.resources.ic_radioactive
import org.meshtastic.core.resources.ic_soil_moisture
import org.meshtastic.core.resources.ic_soil_temperature
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.pressure
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
@@ -222,6 +223,18 @@ internal fun EnvironmentMetrics(
),
)
}
+ // 1-Wire temperature sensors (up to 8 channels)
+ one_wire_temperature
+ .filterNot { it.isNaN() }
+ .forEachIndexed { idx, temp ->
+ add(
+ DrawableMetricInfo(
+ label = Res.string.one_wire_temperature,
+ value = "${idx + 1}: ${temp.toTempString(isFahrenheit)}",
+ icon = Res.drawable.ic_soil_temperature,
+ ),
+ )
+ }
}
}
FlowRow(
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
index bb6efdff6..f8d48dd59 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/CommonCharts.kt
@@ -127,6 +127,8 @@ data class LegendData(
val color: Color,
val isLine: Boolean = false,
val metricKey: Any? = null,
+ /** When non-null, overrides the resolved [nameRes] string in the legend label. */
+ val labelOverride: String? = null,
)
data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color)
@@ -153,11 +155,12 @@ fun Legend(
) {
legendData.forEachIndexed { index, data ->
val isVisible = index !in hiddenSet
+ val label = data.labelOverride ?: stringResource(data.nameRes)
if (onToggle != null) {
FilterChip(
selected = isVisible,
onClick = { onToggle(index) },
- label = { Text(text = stringResource(data.nameRes), style = MaterialTheme.typography.labelSmall) },
+ label = { Text(text = label, style = MaterialTheme.typography.labelSmall) },
leadingIcon = { LegendIndicator(color = data.color, isLine = data.isLine) },
modifier = Modifier.padding(horizontal = 2.dp),
)
@@ -166,7 +169,7 @@ fun Legend(
LegendIndicator(color = data.color, isLine = data.isLine)
Spacer(modifier = Modifier.width(4.dp))
Text(
- text = stringResource(data.nameRes),
+ text = label,
color = MaterialTheme.colorScheme.onSurface,
fontSize = MaterialTheme.typography.labelSmall.fontSize,
)
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
index c0164dd80..0f809ef81 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentCharts.kt
@@ -42,6 +42,7 @@ import org.meshtastic.core.resources.baro_pressure
import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.soil_moisture
import org.meshtastic.core.resources.soil_temperature
@@ -112,6 +113,27 @@ private val LEGEND_DATA_3 =
),
)
+private val LEGEND_DATA_4 =
+ listOf(
+ Environment.ONE_WIRE_TEMP_1,
+ Environment.ONE_WIRE_TEMP_2,
+ Environment.ONE_WIRE_TEMP_3,
+ Environment.ONE_WIRE_TEMP_4,
+ Environment.ONE_WIRE_TEMP_5,
+ Environment.ONE_WIRE_TEMP_6,
+ Environment.ONE_WIRE_TEMP_7,
+ Environment.ONE_WIRE_TEMP_8,
+ )
+ .mapIndexed { index, entry ->
+ LegendData(
+ nameRes = Res.string.one_wire_temperature,
+ labelOverride = "1-Wire Temp ${index + 1}",
+ color = entry.color,
+ isLine = true,
+ metricKey = entry,
+ )
+ }
+
@Suppress("LongMethod", "CyclomaticComplexMethod")
@Composable
fun EnvironmentMetricsChart(
@@ -132,7 +154,7 @@ fun EnvironmentMetricsChart(
val onSurfaceColor = MaterialTheme.colorScheme.onSurface
val allLegendData =
- (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3).filter {
+ (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter {
graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0]
}
@@ -143,7 +165,7 @@ fun EnvironmentMetricsChart(
hiddenIndices.mapNotNull { allLegendData.getOrNull(it)?.metricKey as? Environment }.toSet()
}
- val colorToLabel = allLegendData.associate { it.color to stringResource(it.nameRes) }
+ val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) }
val showPressure =
shouldPlot[Environment.BAROMETRIC_PRESSURE.ordinal] && Environment.BAROMETRIC_PRESSURE !in hiddenMetrics
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
index 4f9e88d47..77c6781f1 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetrics.kt
@@ -54,6 +54,7 @@ import org.meshtastic.core.resources.humidity
import org.meshtastic.core.resources.iaq
import org.meshtastic.core.resources.iaq_definition
import org.meshtastic.core.resources.lux
+import org.meshtastic.core.resources.one_wire_temperature
import org.meshtastic.core.resources.radiation
import org.meshtastic.core.resources.rainfall_1h
import org.meshtastic.core.resources.rainfall_24h
@@ -443,6 +444,39 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics)
}
}
+@Composable
+private fun OneWireTemperatureDisplay(
+ envMetrics: org.meshtastic.proto.EnvironmentMetrics,
+ environmentDisplayFahrenheit: Boolean,
+) {
+ val sensors = envMetrics.one_wire_temperature.filterNot { it.isNaN() }
+ if (sensors.isEmpty()) return
+ val oneWireEntries =
+ listOf(
+ Environment.ONE_WIRE_TEMP_1,
+ Environment.ONE_WIRE_TEMP_2,
+ Environment.ONE_WIRE_TEMP_3,
+ Environment.ONE_WIRE_TEMP_4,
+ Environment.ONE_WIRE_TEMP_5,
+ Environment.ONE_WIRE_TEMP_6,
+ Environment.ONE_WIRE_TEMP_7,
+ Environment.ONE_WIRE_TEMP_8,
+ )
+ val textFormat = if (environmentDisplayFahrenheit) "%s %d: %.1f°F" else "%s %d: %.1f°C"
+ sensors.forEachIndexed { idx, temp ->
+ val color = oneWireEntries.getOrNull(idx)?.color ?: Environment.ONE_WIRE_TEMP_1.color
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ MetricIndicator(color)
+ Spacer(Modifier.width(4.dp))
+ Text(
+ text = formatString(textFormat, stringResource(Res.string.one_wire_temperature), idx + 1, temp),
+ color = MaterialTheme.colorScheme.onSurface,
+ fontSize = MaterialTheme.typography.labelLarge.fontSize,
+ )
+ }
+ }
+}
+
@Composable
private fun EnvironmentMetricsCard(
telemetry: Telemetry,
@@ -484,6 +518,7 @@ private fun EnvironmentMetricsContent(telemetry: Telemetry, environmentDisplayFa
RadiationDisplay(envMetrics)
WindDisplay(envMetrics)
RainfallDisplay(envMetrics)
+ OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit)
}
}
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
index dda094e21..686a228b2 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsState.kt
@@ -18,16 +18,24 @@ package org.meshtastic.feature.node.metrics
import androidx.compose.ui.graphics.Color
import org.meshtastic.core.model.util.UnitConversions
+import org.meshtastic.core.ui.theme.GraphColors.Amber
import org.meshtastic.core.ui.theme.GraphColors.Blue
+import org.meshtastic.core.ui.theme.GraphColors.Chartreuse
+import org.meshtastic.core.ui.theme.GraphColors.Coral
import org.meshtastic.core.ui.theme.GraphColors.Cyan
+import org.meshtastic.core.ui.theme.GraphColors.DeepOrange
import org.meshtastic.core.ui.theme.GraphColors.Gold
import org.meshtastic.core.ui.theme.GraphColors.Green
+import org.meshtastic.core.ui.theme.GraphColors.Indigo
import org.meshtastic.core.ui.theme.GraphColors.InfantryBlue
+import org.meshtastic.core.ui.theme.GraphColors.LightGreen
import org.meshtastic.core.ui.theme.GraphColors.Lime
+import org.meshtastic.core.ui.theme.GraphColors.Magenta
import org.meshtastic.core.ui.theme.GraphColors.Orange
import org.meshtastic.core.ui.theme.GraphColors.Pink
import org.meshtastic.core.ui.theme.GraphColors.Purple
import org.meshtastic.core.ui.theme.GraphColors.Red
+import org.meshtastic.core.ui.theme.GraphColors.SkyBlue
import org.meshtastic.core.ui.theme.GraphColors.Teal
import org.meshtastic.proto.Telemetry
@@ -66,7 +74,39 @@ enum class Environment(val color: Color) {
override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.wind_speed
},
RADIATION(Lime) {
- override fun getValue(telemetry: Telemetry) = telemetry.environment_metrics?.radiation
+ override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.radiation
+ },
+ ONE_WIRE_TEMP_1(Amber) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(0)
+ },
+ ONE_WIRE_TEMP_2(DeepOrange) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1)
+ },
+ ONE_WIRE_TEMP_3(Indigo) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2)
+ },
+ ONE_WIRE_TEMP_4(LightGreen) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3)
+ },
+ ONE_WIRE_TEMP_5(Magenta) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4)
+ },
+ ONE_WIRE_TEMP_6(SkyBlue) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5)
+ },
+ ONE_WIRE_TEMP_7(Chartreuse) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6)
+ },
+ ONE_WIRE_TEMP_8(Coral) {
+ override fun getValue(telemetry: Telemetry): Float? =
+ telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7)
}, ;
abstract fun getValue(telemetry: Telemetry): Float?
@@ -205,6 +245,33 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp
shouldPlot[Environment.RADIATION.ordinal] = true
}
+ // 1-Wire temperature sensors (up to 8 channels, Fahrenheit-aware)
+ val oneWireEntries =
+ listOf(
+ Environment.ONE_WIRE_TEMP_1,
+ Environment.ONE_WIRE_TEMP_2,
+ Environment.ONE_WIRE_TEMP_3,
+ Environment.ONE_WIRE_TEMP_4,
+ Environment.ONE_WIRE_TEMP_5,
+ Environment.ONE_WIRE_TEMP_6,
+ Environment.ONE_WIRE_TEMP_7,
+ Environment.ONE_WIRE_TEMP_8,
+ )
+ oneWireEntries.forEach { entry ->
+ val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } }
+ if (values.isNotEmpty()) {
+ var minVal = values.minOf { it }
+ var maxVal = values.maxOf { it }
+ if (useFahrenheit) {
+ minVal = UnitConversions.celsiusToFahrenheit(minVal)
+ maxVal = UnitConversions.celsiusToFahrenheit(maxVal)
+ }
+ minValues.add(minVal)
+ maxValues.add(maxVal)
+ shouldPlot[entry.ordinal] = true
+ }
+ }
+
val min = if (minValues.isEmpty()) 0f else minValues.minOf { it }
val max = if (maxValues.isEmpty()) 1f else maxValues.maxOf { it }
diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
index b7ab25368..4967e65d5 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt
@@ -148,6 +148,8 @@ open class MetricsViewModel(
temperature = em.temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
soil_temperature =
em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) },
+ one_wire_temperature =
+ em.one_wire_temperature.map { UnitConversions.celsiusToFahrenheit(it) },
),
)
}
@@ -381,21 +383,25 @@ open class MetricsViewModel(
}
fun saveEnvironmentMetricsCSV(uri: MeshtasticUri, data: List) {
+ val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" }
exportCsv(
uri = uri,
header =
"\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," +
"\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," +
- "\"soilMoisture\"\n",
+ "\"soilMoisture\",$oneWireHeaders\n",
rows = data,
epochSeconds = { it.time.toLong() },
) { t ->
val em = t.environment_metrics
+ val owt = em?.one_wire_temperature ?: emptyList()
+ val oneWireValues =
+ (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" }
"\"${em?.temperature ?: ""}\",\"${em?.relative_humidity ?: ""}\"," +
"\"${em?.barometric_pressure ?: ""}\",\"${em?.gas_resistance ?: ""}\"," +
"\"${em?.iaq ?: ""}\",\"${em?.wind_speed ?: ""}\"," +
"\"${em?.wind_direction ?: ""}\",\"${em?.soil_temperature ?: ""}\"," +
- "\"${em?.soil_moisture ?: ""}\""
+ "\"${em?.soil_moisture ?: ""}\",$oneWireValues"
}
}
@@ -457,4 +463,8 @@ open class MetricsViewModel(
}
protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0)
+
+ companion object {
+ private const val ONE_WIRE_SENSOR_COUNT = 8
+ }
}