feat(environment): add 1-Wire multi-thermometer (DS18B20) display support (#5130)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
James Rich 2026-04-14 19:03:24 -05:00 committed by GitHub
parent 099aea2d81
commit f48fc61729
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 162 additions and 7 deletions

View file

@ -748,6 +748,7 @@
<string name="rainfall_24h">Rain (24h)</string>
<string name="weight">Weight</string>
<string name="radiation">Radiation</string>
<string name="one_wire_temperature">1-Wire Temp</string>
<string name="store_forward_config"><![CDATA[Store & Forward Config]]></string>
<string name="indoor_air_quality_iaq">Indoor Air Quality (IAQ)</string>
<string name="url">URL</string>

View file

@ -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 {

View file

@ -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(

View file

@ -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,
)

View file

@ -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

View file

@ -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)
}
}

View file

@ -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<Telemetry> = 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 }

View file

@ -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<Telemetry>) {
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
}
}