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 + } }