diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 6b9c8dcb83..7f2c8efb93 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -27,6 +27,7 @@ action_translate_message actions adc_multiplier_override adc_multiplier_override_ratio +adc_voltage ### ADD ### add add_a_note @@ -1093,6 +1094,7 @@ message_status_sfpp_routing message_status_unknown message_translated_label messages +metric_channel_label micrograms_per_cubic_meter min minimum_broadcast_seconds diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt index e741485d5b..16350420da 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/MeshLogRepositoryImpl.kt @@ -33,10 +33,17 @@ import org.meshtastic.core.database.entity.asEntity import org.meshtastic.core.database.entity.asExternalModel import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT +import org.meshtastic.core.model.util.adcVoltage +import org.meshtastic.core.model.util.oneWireTemperature +import org.meshtastic.core.model.util.withAdcVoltage +import org.meshtastic.core.model.util.withLegacyOneWireTemperatures +import org.meshtastic.core.model.util.withOneWireTemperature import org.meshtastic.core.repository.MeshLogPrefs import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.MeshLogRepository.Companion.DEFAULT_MAX_LOGS import org.meshtastic.core.repository.MeshLogRetention +import org.meshtastic.proto.EnvironmentMetrics import org.meshtastic.proto.MeshPacket import org.meshtastic.proto.MyNodeInfo import org.meshtastic.proto.PortNum @@ -115,7 +122,6 @@ open class MeshLogRepositoryImpl( .distinctUntilChanged() .conflate() - @Suppress("CyclomaticComplexMethod") private fun parseTelemetryLog(log: MeshLog): Telemetry? = runCatching { val decoded = log.fromRadio.packet?.decoded ?: return@runCatching null // Requests for telemetry (want_response = true) should not be logged as data points. @@ -124,22 +130,7 @@ open class MeshLogRepositoryImpl( val telemetry = Telemetry.ADAPTER.decode(decoded.payload) telemetry.copy( time = (log.received_date / MILLIS_PER_SEC).toInt(), - environment_metrics = - telemetry.environment_metrics?.let { metrics -> - metrics.copy( - temperature = metrics.temperature ?: Float.NaN, - relative_humidity = metrics.relative_humidity ?: Float.NaN, - soil_temperature = metrics.soil_temperature ?: Float.NaN, - barometric_pressure = metrics.barometric_pressure ?: Float.NaN, - gas_resistance = metrics.gas_resistance ?: Float.NaN, - voltage = metrics.voltage ?: Float.NaN, - current = metrics.current ?: Float.NaN, - lux = metrics.lux ?: Float.NaN, - uv_lux = metrics.uv_lux ?: Float.NaN, - iaq = metrics.iaq ?: Int.MIN_VALUE, - soil_moisture = metrics.soil_moisture ?: Int.MIN_VALUE, - ) - }, + environment_metrics = telemetry.environment_metrics?.withSentinelsForAbsentReadings(), ) } .getOrNull() @@ -244,3 +235,35 @@ open class MeshLogRepositoryImpl( private const val TELEMETRY_SNAPSHOT_PAGE_SIZE = 512 } } + +/** + * Replaces absent optional readings with the sentinel the graphing layer filters on, so every field reaches the charts + * through one representation. + * + * Only presence is normalized: a reported `0` is a real reading on every field here and is preserved. Historical logs + * predating firmware 2.8 carry 1-Wire temperatures in the deprecated repeated field, so those are lifted onto the + * per-channel fields first — see [withLegacyOneWireTemperatures]. + */ +private fun EnvironmentMetrics.withSentinelsForAbsentReadings(): EnvironmentMetrics = + withLegacyOneWireTemperatures().withScalarSentinels().withChannelSentinels() + +private fun EnvironmentMetrics.withScalarSentinels(): EnvironmentMetrics = copy( + temperature = temperature ?: Float.NaN, + relative_humidity = relative_humidity ?: Float.NaN, + soil_temperature = soil_temperature ?: Float.NaN, + barometric_pressure = barometric_pressure ?: Float.NaN, + gas_resistance = gas_resistance ?: Float.NaN, + voltage = voltage ?: Float.NaN, + current = current ?: Float.NaN, + lux = lux ?: Float.NaN, + uv_lux = uv_lux ?: Float.NaN, + iaq = iaq ?: Int.MIN_VALUE, + soil_moisture = soil_moisture ?: Int.MIN_VALUE, +) + +private fun EnvironmentMetrics.withChannelSentinels(): EnvironmentMetrics = + (0 until TELEMETRY_CHANNEL_COUNT).fold(this) { metrics, channel -> + metrics + .withOneWireTemperature(channel, metrics.oneWireTemperature(channel) ?: Float.NaN) + .withAdcVoltage(channel, metrics.adcVoltage(channel) ?: Float.NaN) + } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt index c0ca7c1887..e3428dd117 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/repository/CommonMeshLogRepositoryTest.kt @@ -29,6 +29,9 @@ import org.meshtastic.core.data.datasource.NodeInfoReadDataSource import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.di.CoroutineDispatchers import org.meshtastic.core.model.MeshLog +import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT +import org.meshtastic.core.model.util.adcVoltage +import org.meshtastic.core.model.util.oneWireTemperature import org.meshtastic.core.repository.MeshLogRetention import org.meshtastic.core.testing.FakeDatabaseProvider import org.meshtastic.core.testing.FakeMeshLogPrefs @@ -233,6 +236,53 @@ abstract class CommonMeshLogRepositoryTest { private fun retentionLog(uuid: String, receivedDate: Long) = MeshLog(uuid = uuid, message_type = "TEXT", received_date = receivedDate, raw_message = "") + @Test + fun `parseTelemetryLog lifts legacy one-wire list onto per-channel fields`() = runTest(testDispatcher) { + // Firmware before 2.8 emitted the repeated field; stored logs must still chart after the repoint. + @Suppress("DEPRECATION") + val telemetry = + Telemetry(environment_metrics = EnvironmentMetrics(one_wire_temperature = listOf(11f, 0f, 33f))) + repository.insert(telemetryLog("legacy-one-wire", 0, telemetry, nowMillis)) + + val metrics = repository.getTelemetryFrom(0).first().single().environment_metrics + assertNotNull(metrics) + + assertEquals(11f, metrics.oneWireTemperature(0)!!, 0.01f) + // A stored 0°C is a real reading, so it must survive the lift rather than reading as absent. + assertEquals(0f, metrics.oneWireTemperature(1)!!, 0.01f) + assertEquals(33f, metrics.oneWireTemperature(2)!!, 0.01f) + // Channels the legacy list never carried normalize to the NaN the charts filter on. + assertTrue(metrics.oneWireTemperature(3)!!.isNaN()) + } + + @Test + fun `parseTelemetryLog normalizes absent per-channel readings to NaN`() = runTest(testDispatcher) { + val telemetry = Telemetry(environment_metrics = EnvironmentMetrics(temperature = 21f)) + repository.insert(telemetryLog("absent-channels", 0, telemetry, nowMillis)) + + val metrics = repository.getTelemetryFrom(0).first().single().environment_metrics + assertNotNull(metrics) + + for (channel in 0 until TELEMETRY_CHANNEL_COUNT) { + assertTrue(metrics.oneWireTemperature(channel)!!.isNaN(), "1-Wire ch$channel should be NaN") + assertTrue(metrics.adcVoltage(channel)!!.isNaN(), "ADC ch$channel should be NaN") + } + } + + @Test + fun `parseTelemetryLog preserves zero per-channel readings`() = runTest(testDispatcher) { + // 0 V on an unloaded ADC input and 0°C on a probe are measurements, not "no sensor" sentinels. + val telemetry = + Telemetry(environment_metrics = EnvironmentMetrics(one_wire_temperature_ch0 = 0f, adc_voltage_ch0 = 0f)) + repository.insert(telemetryLog("zero-channels", 0, telemetry, nowMillis)) + + val metrics = repository.getTelemetryFrom(0).first().single().environment_metrics + assertNotNull(metrics) + + assertEquals(0f, metrics.oneWireTemperature(0)!!, 0.01f) + assertEquals(0f, metrics.adcVoltage(0)!!, 0.01f) + } + private fun telemetryLog( uuid: String, nodeNum: Int, diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt index 1b39c88e65..9851c27ecc 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportNodeDatabaseUseCase.kt @@ -130,6 +130,8 @@ private fun DeviceMetrics.toExport(): DeviceMetricsExport? = DeviceMetricsExport ) .takeUnless { it == DeviceMetricsExport() } +// Reads the deprecated repeated one_wire_temperature so pre-2.8 stored telemetry still round-trips. +@Suppress("DEPRECATION") private fun EnvironmentMetrics.toExport(): EnvironmentMetricsExport? = EnvironmentMetricsExport( temperature = temperature, relativeHumidity = relative_humidity, @@ -154,6 +156,22 @@ private fun EnvironmentMetrics.toExport(): EnvironmentMetricsExport? = Environme soilMoisture = soil_moisture, soilTemperature = soil_temperature, oneWireTemperature = one_wire_temperature.takeIf { it.isNotEmpty() }, + oneWireTemperatureCh0 = one_wire_temperature_ch0, + oneWireTemperatureCh1 = one_wire_temperature_ch1, + oneWireTemperatureCh2 = one_wire_temperature_ch2, + oneWireTemperatureCh3 = one_wire_temperature_ch3, + oneWireTemperatureCh4 = one_wire_temperature_ch4, + oneWireTemperatureCh5 = one_wire_temperature_ch5, + oneWireTemperatureCh6 = one_wire_temperature_ch6, + oneWireTemperatureCh7 = one_wire_temperature_ch7, + adcVoltageCh0 = adc_voltage_ch0, + adcVoltageCh1 = adc_voltage_ch1, + adcVoltageCh2 = adc_voltage_ch2, + adcVoltageCh3 = adc_voltage_ch3, + adcVoltageCh4 = adc_voltage_ch4, + adcVoltageCh5 = adc_voltage_ch5, + adcVoltageCh6 = adc_voltage_ch6, + adcVoltageCh7 = adc_voltage_ch7, ) .takeUnless { it == EnvironmentMetricsExport() } diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt index 613273e750..e6c98da160 100644 --- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt +++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/NodeDatabaseExport.kt @@ -118,7 +118,27 @@ data class EnvironmentMetricsExport( val rainfall24h: Float? = null, val soilMoisture: Int? = null, val soilTemperature: Float? = null, + /** + * Legacy repeated 1-Wire readings. Firmware 2.8 moved these to [oneWireTemperatureCh0]..[oneWireTemperatureCh7]; + * retained so exports of telemetry stored before then keep their original shape. + */ val oneWireTemperature: List? = null, + val oneWireTemperatureCh0: Float? = null, + val oneWireTemperatureCh1: Float? = null, + val oneWireTemperatureCh2: Float? = null, + val oneWireTemperatureCh3: Float? = null, + val oneWireTemperatureCh4: Float? = null, + val oneWireTemperatureCh5: Float? = null, + val oneWireTemperatureCh6: Float? = null, + val oneWireTemperatureCh7: Float? = null, + val adcVoltageCh0: Float? = null, + val adcVoltageCh1: Float? = null, + val adcVoltageCh2: Float? = null, + val adcVoltageCh3: Float? = null, + val adcVoltageCh4: Float? = null, + val adcVoltageCh5: Float? = null, + val adcVoltageCh6: Float? = null, + val adcVoltageCh7: Float? = null, ) @Serializable diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TelemetryChannels.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TelemetryChannels.kt new file mode 100644 index 0000000000..dd8c311f6b --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/TelemetryChannels.kt @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 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 . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.core.model.util + +import org.meshtastic.proto.EnvironmentMetrics + +/** + * Channel count for the per-channel `EnvironmentMetrics` telemetry fields — `one_wire_temperature_ch0..ch7` and + * `adc_voltage_ch0..ch7`. + */ +const val TELEMETRY_CHANNEL_COUNT: Int = 8 + +/** + * Index-to-field accessors for the per-channel telemetry fields. + * + * Wire generates one named property per channel, so indexed access needs an explicit map rather than a list lookup. + * Each field is `optional`, making `null` the only "channel absent" signal — a returned `0f` is a real reading (0 V on + * an unloaded ADC input, 0°C on a probe at freezing), so callers must not treat zero as missing. + * + * Reading past [TELEMETRY_CHANNEL_COUNT] returns `null` rather than throwing, so a firmware that grows the channel + * range degrades to "not shown" instead of crashing. + */ +fun EnvironmentMetrics.oneWireTemperature(channel: Int): Float? = when (channel) { + 0 -> one_wire_temperature_ch0 + 1 -> one_wire_temperature_ch1 + 2 -> one_wire_temperature_ch2 + 3 -> one_wire_temperature_ch3 + 4 -> one_wire_temperature_ch4 + 5 -> one_wire_temperature_ch5 + 6 -> one_wire_temperature_ch6 + 7 -> one_wire_temperature_ch7 + else -> null +} + +fun EnvironmentMetrics.adcVoltage(channel: Int): Float? = when (channel) { + 0 -> adc_voltage_ch0 + 1 -> adc_voltage_ch1 + 2 -> adc_voltage_ch2 + 3 -> adc_voltage_ch3 + 4 -> adc_voltage_ch4 + 5 -> adc_voltage_ch5 + 6 -> adc_voltage_ch6 + 7 -> adc_voltage_ch7 + else -> null +} + +/** Returns a copy with 1-Wire [channel] set to [value]; an out-of-range [channel] is a no-op. */ +fun EnvironmentMetrics.withOneWireTemperature(channel: Int, value: Float?): EnvironmentMetrics = when (channel) { + 0 -> copy(one_wire_temperature_ch0 = value) + 1 -> copy(one_wire_temperature_ch1 = value) + 2 -> copy(one_wire_temperature_ch2 = value) + 3 -> copy(one_wire_temperature_ch3 = value) + 4 -> copy(one_wire_temperature_ch4 = value) + 5 -> copy(one_wire_temperature_ch5 = value) + 6 -> copy(one_wire_temperature_ch6 = value) + 7 -> copy(one_wire_temperature_ch7 = value) + else -> this +} + +/** Returns a copy with ADC [channel] set to [value]; an out-of-range [channel] is a no-op. */ +fun EnvironmentMetrics.withAdcVoltage(channel: Int, value: Float?): EnvironmentMetrics = when (channel) { + 0 -> copy(adc_voltage_ch0 = value) + 1 -> copy(adc_voltage_ch1 = value) + 2 -> copy(adc_voltage_ch2 = value) + 3 -> copy(adc_voltage_ch3 = value) + 4 -> copy(adc_voltage_ch4 = value) + 5 -> copy(adc_voltage_ch5 = value) + 6 -> copy(adc_voltage_ch6 = value) + 7 -> copy(adc_voltage_ch7 = value) + else -> this +} + +/** + * Lifts the deprecated `repeated one_wire_temperature` list onto the per-channel fields, for telemetry stored before + * firmware 2.8 moved to `one_wire_temperature_chN`. + * + * Upstream flipped field 23 to `FT_IGNORE`, so current firmware never emits it and this is a read path for historical + * logs only. Channels already carrying a per-channel value win, so a live packet is never overwritten by legacy data. + */ +@Suppress("DEPRECATION") +fun EnvironmentMetrics.withLegacyOneWireTemperatures(): EnvironmentMetrics = + one_wire_temperature.take(TELEMETRY_CHANNEL_COUNT).foldIndexed(this) { channel, metrics, legacy -> + if (metrics.oneWireTemperature(channel) == null) { + metrics.withOneWireTemperature(channel, legacy) + } else { + metrics + } + } diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/TelemetryChannelsTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/TelemetryChannelsTest.kt new file mode 100644 index 0000000000..6a3e58efe8 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/TelemetryChannelsTest.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2026 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 org.meshtastic.core.model.util + +import org.meshtastic.proto.EnvironmentMetrics +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The per-channel telemetry fields are `optional`, so `null` is the only "channel absent" signal and a reported `0` is + * a real reading. Both cases are pinned here — either alone lets the two states collapse back into one. + */ +class TelemetryChannelsTest { + + @Test + fun oneWireAccessorReadsEveryChannelInOrder() { + val metrics = + EnvironmentMetrics( + one_wire_temperature_ch0 = 0f, + one_wire_temperature_ch1 = 1f, + one_wire_temperature_ch2 = 2f, + one_wire_temperature_ch3 = 3f, + one_wire_temperature_ch4 = 4f, + one_wire_temperature_ch5 = 5f, + one_wire_temperature_ch6 = 6f, + one_wire_temperature_ch7 = 7f, + ) + + for (channel in 0 until TELEMETRY_CHANNEL_COUNT) { + assertEquals(channel.toFloat(), metrics.oneWireTemperature(channel)) + } + } + + @Test + fun adcAccessorReadsEveryChannelInOrder() { + val metrics = + EnvironmentMetrics( + adc_voltage_ch0 = 0f, + adc_voltage_ch1 = 1f, + adc_voltage_ch2 = 2f, + adc_voltage_ch3 = 3f, + adc_voltage_ch4 = 4f, + adc_voltage_ch5 = 5f, + adc_voltage_ch6 = 6f, + adc_voltage_ch7 = 7f, + ) + + for (channel in 0 until TELEMETRY_CHANNEL_COUNT) { + assertEquals(channel.toFloat(), metrics.adcVoltage(channel)) + } + } + + @Test + fun measuredZeroIsDistinctFromAbsentChannel() { + val reported = EnvironmentMetrics(one_wire_temperature_ch3 = 0f, adc_voltage_ch3 = 0f) + + assertEquals(0f, reported.oneWireTemperature(3)) + assertEquals(0f, reported.adcVoltage(3)) + assertNull(EnvironmentMetrics().oneWireTemperature(3)) + assertNull(EnvironmentMetrics().adcVoltage(3)) + } + + @Test + fun outOfRangeChannelIsNullRatherThanAnError() { + val metrics = EnvironmentMetrics(one_wire_temperature_ch0 = 1f, adc_voltage_ch0 = 1f) + + assertNull(metrics.oneWireTemperature(TELEMETRY_CHANNEL_COUNT)) + assertNull(metrics.adcVoltage(TELEMETRY_CHANNEL_COUNT)) + assertNull(metrics.oneWireTemperature(-1)) + assertNull(metrics.adcVoltage(-1)) + } + + @Test + fun withAccessorsSetOnlyTheTargetChannel() { + val metrics = EnvironmentMetrics().withOneWireTemperature(2, 12.5f).withAdcVoltage(5, 1.8f) + + assertEquals(12.5f, metrics.oneWireTemperature(2)) + assertEquals(1.8f, metrics.adcVoltage(5)) + assertNull(metrics.oneWireTemperature(1)) + assertNull(metrics.adcVoltage(4)) + } + + @Test + fun withAccessorsIgnoreOutOfRangeChannels() { + val metrics = EnvironmentMetrics() + + assertEquals(metrics, metrics.withOneWireTemperature(TELEMETRY_CHANNEL_COUNT, 1f)) + assertEquals(metrics, metrics.withAdcVoltage(TELEMETRY_CHANNEL_COUNT, 1f)) + } + + // ---- legacy repeated-field fallback ---- + + @Suppress("DEPRECATION") + @Test + fun legacyListIsLiftedOntoPerChannelFields() { + val stored = EnvironmentMetrics(one_wire_temperature = listOf(10f, 0f, 30f)) + + val lifted = stored.withLegacyOneWireTemperatures() + + assertEquals(10f, lifted.oneWireTemperature(0)) + // A stored 0°C is a real historical reading, so it must survive the lift. + assertEquals(0f, lifted.oneWireTemperature(1)) + assertEquals(30f, lifted.oneWireTemperature(2)) + assertNull(lifted.oneWireTemperature(3)) + } + + @Suppress("DEPRECATION") + @Test + fun perChannelValuesWinOverLegacyList() { + val mixed = EnvironmentMetrics(one_wire_temperature = listOf(10f, 20f), one_wire_temperature_ch0 = 99f) + + val lifted = mixed.withLegacyOneWireTemperatures() + + assertEquals(99f, lifted.oneWireTemperature(0)) + assertEquals(20f, lifted.oneWireTemperature(1)) + } + + @Suppress("DEPRECATION") + @Test + fun legacyListLongerThanTheChannelRangeIsTruncated() { + val stored = EnvironmentMetrics(one_wire_temperature = List(12) { it.toFloat() }) + + val lifted = stored.withLegacyOneWireTemperatures() + + assertEquals(7f, lifted.oneWireTemperature(TELEMETRY_CHANNEL_COUNT - 1)) + assertNull(lifted.oneWireTemperature(TELEMETRY_CHANNEL_COUNT)) + } + + @Test + fun absentLegacyListLeavesMetricsUnchanged() { + val metrics = EnvironmentMetrics(temperature = 21f) + + assertEquals(metrics, metrics.withLegacyOneWireTemperatures()) + } +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index bc1d1fbbb8..5370b0112c 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -45,6 +45,7 @@ Actions ADC multiplier override ADC multiplier override ratio + ADC Voltage Add Add a private note… @@ -1126,6 +1127,7 @@ Unknown Translated Messages + %1$s %2$d µg/m³ Min Minimum broadcast (seconds) 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 035c95f595..a528a0e20c 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 @@ -152,6 +152,17 @@ object GraphColors { val SkyBlue = Color(0xFF03A9F4) val Chartreuse = Color(0xFF76FF03) val Coral = Color(0xFFFF6E40) + + // Muted second tier. The vivid hues above are exhausted, so multi-channel series that need their own identity draw + // from here: a chart marker resolves its label by series color, so no two plotted series may share one. + val Brown = Color(0xFF8D6E63) + val BlueGrey = Color(0xFF78909C) + val Olive = Color(0xFF9E9D24) + val DeepPurple = Color(0xFF7E57C2) + val SeaGreen = Color(0xFF2E9E5B) + val Maroon = Color(0xFFA13D63) + val Mustard = Color(0xFFC9A227) + val Slate = Color(0xFF4A6572) } 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 3014fa7411..c921a8081c 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 @@ -20,11 +20,15 @@ import androidx.compose.runtime.Composable import org.meshtastic.core.common.util.MetricFormatter import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.model.util.UnitConversions.toTempString +import org.meshtastic.core.model.util.adcVoltage +import org.meshtastic.core.model.util.oneWireTemperature import org.meshtastic.core.model.util.toSmallDistanceString import org.meshtastic.core.model.util.toSpeedString import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.adc_voltage import org.meshtastic.core.resources.current import org.meshtastic.core.resources.dew_point import org.meshtastic.core.resources.distance @@ -32,6 +36,7 @@ import org.meshtastic.core.resources.gas_resistance import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq import org.meshtastic.core.resources.ic_dew_point +import org.meshtastic.core.resources.ic_electric_bolt import org.meshtastic.core.resources.ic_radioactive import org.meshtastic.core.resources.ic_soil_moisture import org.meshtastic.core.resources.ic_soil_temperature @@ -240,19 +245,38 @@ internal fun EnvironmentMetrics( .asGroup(), ) } - // 1-Wire temperature sensors (up to 8 channels) — independent probes, so one card each. - 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, + // 1-Wire probes and ADC inputs are independent channels, so one card each. Absent channels are null; a + // reported 0°C or 0 V is a real reading and stays visible. + for (channel in 0 until TELEMETRY_CHANNEL_COUNT) { + oneWireTemperature(channel) + ?.takeIf { !it.isNaN() } + ?.let { temp -> + add( + DrawableMetricInfo( + label = Res.string.one_wire_temperature, + value = temp.toTempString(isFahrenheit), + icon = Res.drawable.ic_soil_temperature, + channelNumber = channel + 1, + ) + .asGroup(), ) - .asGroup(), - ) - } + } + } + for (channel in 0 until TELEMETRY_CHANNEL_COUNT) { + adcVoltage(channel) + ?.takeIf { !it.isNaN() } + ?.let { volts -> + add( + DrawableMetricInfo( + label = Res.string.adc_voltage, + value = MetricFormatter.voltage(volts), + icon = Res.drawable.ic_electric_bolt, + channelNumber = channel + 1, + ) + .asGroup(), + ) + } + } } } MetricCardFlow(groups = groups) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt index dbfff72057..4084837b58 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/MetricCardFlow.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.metric_channel_label import org.meshtastic.feature.node.model.DrawableMetricInfo import org.meshtastic.feature.node.model.MetricInfo import org.meshtastic.feature.node.model.VectorMetricInfo @@ -99,7 +101,9 @@ internal fun MetricCardFlow( @Composable private fun MetricCard(metric: MetricInfo, valueColor: Color?) { val cardModifier = Modifier.fillMaxWidth() - val label = stringResource(metric.label) + val label = + metric.channelNumber?.let { stringResource(Res.string.metric_channel_label, stringResource(metric.label), it) } + ?: stringResource(metric.label) val resolvedValueColor = valueColor ?: MaterialTheme.colorScheme.onSurface when (metric) { is VectorMetricInfo -> 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 7c5428da99..509049defb 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 @@ -63,6 +63,7 @@ import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC import org.meshtastic.core.resources.Res import org.meshtastic.core.resources.close import org.meshtastic.core.resources.info +import org.meshtastic.core.resources.metric_channel_label import org.meshtastic.core.resources.rssi import org.meshtastic.core.resources.snr import org.meshtastic.core.ui.icon.Info @@ -139,10 +140,16 @@ 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, + /** 1-based channel number appended to the resolved [nameRes], for multi-channel series. */ + val channelNumber: Int? = null, ) +/** Resolves a legend's display label, appending [LegendData.channelNumber] when the series is one of several. */ +@Composable +fun legendLabel(data: LegendData): String = + data.channelNumber?.let { stringResource(Res.string.metric_channel_label, stringResource(data.nameRes), it) } + ?: stringResource(data.nameRes) + data class InfoDialogData(val titleRes: StringResource, val definitionRes: StringResource, val color: Color) /** @@ -167,7 +174,7 @@ fun Legend( ) { legendData.forEachIndexed { index, data -> val isVisible = index !in hiddenSet - val label = data.labelOverride ?: stringResource(data.nameRes) + val label = legendLabel(data) if (onToggle != null) { FilterChip( selected = isVisible, 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 caf974c873..65cfd93717 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 @@ -36,10 +36,10 @@ import com.patrykandpatrick.vico.compose.cartesian.data.lineModel import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer import com.patrykandpatrick.vico.compose.cartesian.layer.rememberLineCartesianLayer import com.patrykandpatrick.vico.compose.common.data.ExtraStore -import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.util.UnitConversions import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.adc_voltage import org.meshtastic.core.resources.baro_pressure import org.meshtastic.core.resources.humidity import org.meshtastic.core.resources.iaq @@ -116,25 +116,26 @@ 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, - ) - } + Environment.oneWireTemperatures.mapIndexed { index, entry -> + LegendData( + nameRes = Res.string.one_wire_temperature, + channelNumber = index + 1, + color = entry.color, + isLine = true, + metricKey = entry, + ) + } + +private val LEGEND_DATA_5 = + Environment.adcVoltages.mapIndexed { index, entry -> + LegendData( + nameRes = Res.string.adc_voltage, + channelNumber = index + 1, + color = entry.color, + isLine = true, + metricKey = entry, + ) + } private const val PRESSURE_DEFAULT_MIN = 950.0 private const val PRESSURE_DEFAULT_MAX = 1050.0 @@ -168,20 +169,14 @@ internal fun chartValue(metric: Environment, telemetry: Telemetry, isImperial: B * Unit suffix for a plotted metric's axis and marker labels, in the user's display units, or "" for metrics whose unit * would be noise on a shared axis. Includes any leading space, so it appends directly to a formatted value. */ -internal fun unitSuffix(metric: Environment, isFahrenheit: Boolean, isImperial: Boolean): String = when (metric) { - Environment.TEMPERATURE, - Environment.SOIL_TEMPERATURE, - 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, - -> if (isFahrenheit) "°F" else "°C" +internal fun unitSuffix(metric: Environment, isFahrenheit: Boolean, isImperial: Boolean): String = when { + metric == Environment.TEMPERATURE || + metric == Environment.SOIL_TEMPERATURE || + metric in Environment.oneWireTemperatures -> if (isFahrenheit) "°F" else "°C" - Environment.WIND_SPEED -> if (isImperial) " mph" else " m/s" + metric in Environment.adcVoltages -> " V" + + metric == Environment.WIND_SPEED -> if (isImperial) " mph" else " m/s" else -> "" } @@ -208,7 +203,7 @@ fun EnvironmentMetricsChart( val onSurfaceColor = MaterialTheme.colorScheme.onSurface val allLegendData = - (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4).filter { + (LEGEND_DATA_1 + LEGEND_DATA_2 + LEGEND_DATA_3 + LEGEND_DATA_4 + LEGEND_DATA_5).filter { graphData.shouldPlot[(it.metricKey as? Environment)?.ordinal ?: 0] } @@ -219,7 +214,7 @@ fun EnvironmentMetricsChart( allLegendData.indices.filter { (allLegendData[it].metricKey as? Environment) in hiddenMetrics }.toSet() } - val colorToLabel = allLegendData.associate { it.color to (it.labelOverride ?: stringResource(it.nameRes)) } + val colorToLabel = allLegendData.associate { it.color to legendLabel(it) } val colorToUnit = allLegendData.associate { legend -> val metric = legend.metricKey as? Environment 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 7a6d1a5a63..b11d2cee17 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 @@ -36,6 +36,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.unit.dp @@ -43,17 +44,23 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.jetbrains.compose.resources.stringResource import org.meshtastic.core.common.util.DateFormatter import org.meshtastic.core.common.util.MetricFormatter +import org.meshtastic.core.common.util.NumberFormatter import org.meshtastic.core.common.util.formatString import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC +import org.meshtastic.core.model.util.adcVoltage +import org.meshtastic.core.model.util.oneWireTemperature import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.adc_voltage import org.meshtastic.core.resources.current +import org.meshtastic.core.resources.device_metrics_label_value import org.meshtastic.core.resources.env_metrics_log import org.meshtastic.core.resources.gas_resistance 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.metric_channel_label import org.meshtastic.core.resources.one_wire_temperature import org.meshtastic.core.resources.radiation import org.meshtastic.core.resources.rainfall_1h @@ -479,36 +486,56 @@ private fun RainfallDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics, } } +/** + * One row per reporting 1-Wire probe. Values arrive already converted to the display unit by the view model, so they + * are only formatted here — a second conversion would double-count. An absent channel is `null`; 0°C is a real reading. + */ @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 unit = if (environmentDisplayFahrenheit) "°F" else "°C" + Environment.oneWireTemperatures.forEachIndexed { idx, entry -> + val temp = envMetrics.oneWireTemperature(idx)?.takeIf { !it.isNaN() } ?: return@forEachIndexed + ChannelMetricRow( + color = entry.color, + label = stringResource(Res.string.one_wire_temperature), + channelNumber = idx + 1, + value = "${NumberFormatter.format(temp, 1)}$unit", + ) + } +} + +/** One row per reporting ADC channel. Volts need no unit conversion, and 0 V is a real reading. */ +@Composable +private fun AdcVoltageDisplay(envMetrics: org.meshtastic.proto.EnvironmentMetrics) { + Environment.adcVoltages.forEachIndexed { idx, entry -> + val volts = envMetrics.adcVoltage(idx)?.takeIf { !it.isNaN() } ?: return@forEachIndexed + ChannelMetricRow( + color = entry.color, + label = stringResource(Res.string.adc_voltage), + channelNumber = idx + 1, + value = MetricFormatter.voltage(volts), + ) + } +} + +@Composable +private fun ChannelMetricRow(color: Color, label: String, channelNumber: Int, value: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + MetricIndicator(color) + Spacer(Modifier.width(4.dp)) + Text( + text = + stringResource( + Res.string.device_metrics_label_value, + stringResource(Res.string.metric_channel_label, label, channelNumber), + value, + ), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.labelLarge, ) - 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, - style = MaterialTheme.typography.labelLarge, - ) - } } } @@ -560,6 +587,7 @@ private fun EnvironmentMetricsContent( WindDisplay(envMetrics, isImperial) RainfallDisplay(envMetrics, isImperial) OneWireTemperatureDisplay(envMetrics, environmentDisplayFahrenheit) + AdcVoltageDisplay(envMetrics) } } 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 2b86bcee9e..9977a59ce4 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 @@ -20,10 +20,13 @@ 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.BlueGrey +import org.meshtastic.core.ui.theme.GraphColors.Brown 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.DeepPurple import org.meshtastic.core.ui.theme.GraphColors.Gold import org.meshtastic.core.ui.theme.GraphColors.Green import org.meshtastic.core.ui.theme.GraphColors.Indigo @@ -31,11 +34,16 @@ 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.Maroon +import org.meshtastic.core.ui.theme.GraphColors.Mustard +import org.meshtastic.core.ui.theme.GraphColors.Olive 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.SeaGreen import org.meshtastic.core.ui.theme.GraphColors.SkyBlue +import org.meshtastic.core.ui.theme.GraphColors.Slate import org.meshtastic.core.ui.theme.GraphColors.Teal import org.meshtastic.proto.Telemetry @@ -77,39 +85,83 @@ enum class Environment(val color: Color) { 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) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch0 }, ONE_WIRE_TEMP_2(DeepOrange) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(1) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch1 }, ONE_WIRE_TEMP_3(Indigo) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(2) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch2 }, ONE_WIRE_TEMP_4(LightGreen) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(3) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch3 }, ONE_WIRE_TEMP_5(Magenta) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(4) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch4 }, ONE_WIRE_TEMP_6(SkyBlue) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(5) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch5 }, ONE_WIRE_TEMP_7(Chartreuse) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(6) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch6 }, ONE_WIRE_TEMP_8(Coral) { - override fun getValue(telemetry: Telemetry): Float? = - telemetry.environment_metrics?.one_wire_temperature?.getOrNull(7) + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.one_wire_temperature_ch7 + }, + ADC_VOLTAGE_1(Brown) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch0 + }, + ADC_VOLTAGE_2(BlueGrey) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch1 + }, + ADC_VOLTAGE_3(Olive) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch2 + }, + ADC_VOLTAGE_4(DeepPurple) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch3 + }, + ADC_VOLTAGE_5(SeaGreen) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch4 + }, + ADC_VOLTAGE_6(Maroon) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch5 + }, + ADC_VOLTAGE_7(Mustard) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch6 + }, + ADC_VOLTAGE_8(Slate) { + override fun getValue(telemetry: Telemetry): Float? = telemetry.environment_metrics?.adc_voltage_ch7 }, ; abstract fun getValue(telemetry: Telemetry): Float? + + companion object { + /** The 1-Wire probe series, ordered to match `one_wire_temperature_ch0..ch7`. */ + val oneWireTemperatures: List = + listOf( + ONE_WIRE_TEMP_1, + ONE_WIRE_TEMP_2, + ONE_WIRE_TEMP_3, + ONE_WIRE_TEMP_4, + ONE_WIRE_TEMP_5, + ONE_WIRE_TEMP_6, + ONE_WIRE_TEMP_7, + ONE_WIRE_TEMP_8, + ) + + /** The multi-channel ADC series, ordered to match `adc_voltage_ch0..ch7`. */ + val adcVoltages: List = + listOf( + ADC_VOLTAGE_1, + ADC_VOLTAGE_2, + ADC_VOLTAGE_3, + ADC_VOLTAGE_4, + ADC_VOLTAGE_5, + ADC_VOLTAGE_6, + ADC_VOLTAGE_7, + ADC_VOLTAGE_8, + ) + } } /** @@ -246,18 +298,7 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } // 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 -> + Environment.oneWireTemperatures.forEach { entry -> val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } } if (values.isNotEmpty()) { var minVal = values.minOf { it } @@ -272,6 +313,17 @@ data class EnvironmentMetricsState(val environmentMetrics: List = emp } } + // Multi-channel ADC voltage (up to 8 channels). Volts need no unit conversion. 0 V is a real reading, so only + // NaN is filtered here. + Environment.adcVoltages.forEach { entry -> + val values = telemetries.mapNotNull { entry.getValue(it)?.takeIf { v -> !v.isNaN() } } + if (values.isNotEmpty()) { + minValues.add(values.minOf { it }) + maxValues.add(values.maxOf { it }) + 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 a29bce2f70..c335ec1162 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 @@ -48,9 +48,13 @@ import org.meshtastic.core.model.TelemetryType import org.meshtastic.core.model.TracerouteOverlay import org.meshtastic.core.model.evaluateTracerouteMapAvailability import org.meshtastic.core.model.util.GeoConstants +import org.meshtastic.core.model.util.TELEMETRY_CHANNEL_COUNT import org.meshtastic.core.model.util.UnitConversions +import org.meshtastic.core.model.util.adcVoltage +import org.meshtastic.core.model.util.oneWireTemperature import org.meshtastic.core.model.util.rxTimeOrNull import org.meshtastic.core.model.util.snrOrNull +import org.meshtastic.core.model.util.withOneWireTemperature import org.meshtastic.core.repository.FileService import org.meshtastic.core.repository.MeshLogRepository import org.meshtastic.core.repository.NodeRepository @@ -145,16 +149,19 @@ open class MetricsViewModel( if (currentState.isFahrenheit) { data.map { telemetry -> val em = telemetry.environment_metrics ?: return@map telemetry - telemetry.copy( - environment_metrics = + // Each 1-Wire channel converts independently; an absent channel must stay null rather than + // becoming a converted zero. + var converted = em.copy( 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) }, - ), - ) + soil_temperature = em.soil_temperature?.let { UnitConversions.celsiusToFahrenheit(it) }, + ) + for (channel in 0 until TELEMETRY_CHANNEL_COUNT) { + val celsius = em.oneWireTemperature(channel) ?: continue + converted = + converted.withOneWireTemperature(channel, UnitConversions.celsiusToFahrenheit(celsius)) + } + telemetry.copy(environment_metrics = converted) } } else { data @@ -427,25 +434,32 @@ open class MetricsViewModel( } fun saveEnvironmentMetricsCSV(uri: CommonUri, data: List) { - val oneWireHeaders = (1..ONE_WIRE_SENSOR_COUNT).joinToString(",") { "\"oneWireTemp$it\"" } + val oneWireHeaders = (1..TELEMETRY_CHANNEL_COUNT).joinToString(",") { "\"oneWireTemp$it\"" } + val adcHeaders = (1..TELEMETRY_CHANNEL_COUNT).joinToString(",") { "\"adcVoltage$it\"" } exportCsv( uri = uri, header = "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\"," + "\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\"," + - "\"soilMoisture\",$oneWireHeaders\n", + "\"soilMoisture\",$oneWireHeaders,$adcHeaders\n", rows = data, epochSeconds = { it.time.toLong() }, ) { t -> val em = t.environment_metrics - val owt = em?.one_wire_temperature ?: emptyList() + // An absent channel exports as an empty field, keeping it distinguishable from a measured 0°C / 0 V. val oneWireValues = - (0 until ONE_WIRE_SENSOR_COUNT).joinToString(",") { i -> "\"${owt.getOrNull(i) ?: ""}\"" } + (0 until TELEMETRY_CHANNEL_COUNT).joinToString(",") { i -> + "\"${em?.oneWireTemperature(i)?.takeIf { !it.isNaN() } ?: ""}\"" + } + val adcValues = + (0 until TELEMETRY_CHANNEL_COUNT).joinToString(",") { i -> + "\"${em?.adcVoltage(i)?.takeIf { !it.isNaN() } ?: ""}\"" + } "\"${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 ?: ""}\",$oneWireValues" + "\"${em?.soil_moisture ?: ""}\",$oneWireValues,$adcValues" } } @@ -562,10 +576,6 @@ open class MetricsViewModel( } protected fun decodeBase64(base64: String): ByteArray = base64.decodeBase64()?.toByteArray() ?: ByteArray(0) - - companion object { - private const val ONE_WIRE_SENSOR_COUNT = 8 - } } private fun buildGpx(positions: List, trackName: String): String { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt index 41aae00aa5..a8f9746204 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricInfo.kt @@ -25,6 +25,9 @@ internal sealed interface MetricInfo { val label: StringResource val value: String val rotateIcon: Float + + /** 1-based channel number appended to the resolved [label], for one-card-per-channel readings. */ + val channelNumber: Int? } internal data class VectorMetricInfo( @@ -32,6 +35,7 @@ internal data class VectorMetricInfo( override val value: String, val icon: ImageVector, override val rotateIcon: Float = 0f, + override val channelNumber: Int? = null, ) : MetricInfo internal data class DrawableMetricInfo( @@ -39,4 +43,5 @@ internal data class DrawableMetricInfo( override val value: String, val icon: DrawableResource, override val rotateIcon: Float = 0f, + override val channelNumber: Int? = null, ) : MetricInfo diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt index 2c631b3676..900ce393cb 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentChartUnitsTest.kt @@ -83,6 +83,20 @@ class EnvironmentChartUnitsTest { assertEquals("°F", unitSuffix(Environment.ONE_WIRE_TEMP_8, isFahrenheit = true, isImperial = false)) } + @Test + fun adcVoltageSuffixIsVoltsRegardlessOfDisplayUnits() { + assertEquals(" V", unitSuffix(Environment.ADC_VOLTAGE_1, isFahrenheit = false, isImperial = false)) + assertEquals(" V", unitSuffix(Environment.ADC_VOLTAGE_8, isFahrenheit = true, isImperial = true)) + } + + /** ADC readings are already in volts, so the chart must not unit-convert them. */ + @Test + fun adcVoltageIsNotConverted() { + val t = telemetry(EnvironmentMetrics(adc_voltage_ch0 = 3.3f)) + + assertEquals(3.3f, chartValue(Environment.ADC_VOLTAGE_1, t, isImperial = true)!!, 0.001f) + } + @Test fun sharedAxisMetricsHaveNoSuffix() { assertEquals("", unitSuffix(Environment.HUMIDITY, isFahrenheit = true, isImperial = true)) diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt index f3185f226f..d1f0fa7f4b 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/EnvironmentMetricsForGraphingTest.kt @@ -217,6 +217,75 @@ class EnvironmentMetricsForGraphingTest { assertEquals(0.15f, result.rightMinMax.second, 0.01f) } + // ---- Per-channel series (1-Wire probes, ADC inputs) ---- + + @Test + fun oneWireChannels_plotIndependently() { + val metrics = + listOf(telemetry(env = EnvironmentMetrics(one_wire_temperature_ch0 = 10f, one_wire_temperature_ch7 = 40f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_1.ordinal]) + assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_8.ordinal]) + // Channels the node never reported must stay unplotted rather than charting as zero. + assertFalse(result.shouldPlot[Environment.ONE_WIRE_TEMP_2.ordinal]) + } + + @Test + fun oneWireChannels_convertToFahrenheit() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(one_wire_temperature_ch2 = 100f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true) + + assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_3.ordinal]) + assertEquals(212f, result.rightMinMax.second, 0.01f) + } + + /** 0°C is a real probe reading, not a "no sensor" sentinel — the series must still plot. */ + @Test + fun oneWireChannel_zeroIsPlotted() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(one_wire_temperature_ch0 = 0f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.ONE_WIRE_TEMP_1.ordinal]) + } + + @Test + fun adcChannels_plotIndependently() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch0 = 3.3f, adc_voltage_ch7 = 1.8f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.ADC_VOLTAGE_1.ordinal]) + assertTrue(result.shouldPlot[Environment.ADC_VOLTAGE_8.ordinal]) + assertFalse(result.shouldPlot[Environment.ADC_VOLTAGE_2.ordinal]) + } + + /** 0 V is a real reading on an unloaded ADC input — the series must still plot. */ + @Test + fun adcChannel_zeroIsPlotted() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch3 = 0f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertTrue(result.shouldPlot[Environment.ADC_VOLTAGE_4.ordinal]) + assertEquals(0f, result.rightMinMax.first, 0.001f) + } + + /** ADC voltages are volts already; the Fahrenheit setting must not touch them. */ + @Test + fun adcChannels_areNotUnitConverted() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch0 = 100f))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing(useFahrenheit = true) + + assertEquals(100f, result.rightMinMax.second, 0.01f) + } + + @Test + fun adcChannel_nanFilteredOut() { + val metrics = listOf(telemetry(env = EnvironmentMetrics(adc_voltage_ch0 = Float.NaN))) + val result = EnvironmentMetricsState(metrics).environmentMetricsForGraphing() + + assertFalse(result.shouldPlot[Environment.ADC_VOLTAGE_1.ordinal]) + } + // ---- NaN filtering ---- @Test diff --git a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt index df7238bff4..9e25dc3347 100644 --- a/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt +++ b/feature/node/src/commonTest/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModelTest.kt @@ -297,7 +297,12 @@ class MetricsViewModelTest { wind_direction = 180, soil_temperature = 18.75f, soil_moisture = 65, - one_wire_temperature = listOf(1f, 2f, 3f), + one_wire_temperature_ch0 = 1f, + one_wire_temperature_ch1 = 2f, + one_wire_temperature_ch2 = 3f, + // 0 V is a real reading on an unloaded ADC input, so it exports as 0.0, not empty. + adc_voltage_ch0 = 3.3f, + adc_voltage_ch1 = 0f, ), ) @@ -331,12 +336,12 @@ class MetricsViewModelTest { val csvOutput = buffer.readUtf8() assertTrue( csvOutput.startsWith( - "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\",\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\",\"soilMoisture\",\"oneWireTemp1\",\"oneWireTemp2\",\"oneWireTemp3\",\"oneWireTemp4\",\"oneWireTemp5\",\"oneWireTemp6\",\"oneWireTemp7\",\"oneWireTemp8\"", + "\"date\",\"time\",\"temperature\",\"relativeHumidity\",\"barometricPressure\",\"gasResistance\",\"iaq\",\"windSpeed\",\"windDirection\",\"soilTemperature\",\"soilMoisture\",\"oneWireTemp1\",\"oneWireTemp2\",\"oneWireTemp3\",\"oneWireTemp4\",\"oneWireTemp5\",\"oneWireTemp6\",\"oneWireTemp7\",\"oneWireTemp8\",\"adcVoltage1\",\"adcVoltage2\",\"adcVoltage3\",\"adcVoltage4\",\"adcVoltage5\",\"adcVoltage6\",\"adcVoltage7\",\"adcVoltage8\"", ), ) assertTrue( csvOutput.contains( - "\"21.5\",\"55.5\",\"1013.25\",\"12.3\",\"42\",\"5.5\",\"180\",\"18.75\",\"65\",\"1.0\",\"2.0\",\"3.0\",\"\",\"\",\"\",\"\",\"\"", + "\"21.5\",\"55.5\",\"1013.25\",\"12.3\",\"42\",\"5.5\",\"180\",\"18.75\",\"65\",\"1.0\",\"2.0\",\"3.0\",\"\",\"\",\"\",\"\",\"\",\"3.3\",\"0.0\",\"\",\"\",\"\",\"\",\"\",\"\"", ), ) diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/component/EnvironmentChannelMetricsTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/component/EnvironmentChannelMetricsTest.kt new file mode 100644 index 0000000000..338492ac6d --- /dev/null +++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/component/EnvironmentChannelMetricsTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2026 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 org.meshtastic.feature.node.component + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.v2.runComposeUiTest +import org.meshtastic.core.model.Node +import org.meshtastic.proto.Config +import kotlin.test.Test +import org.meshtastic.proto.EnvironmentMetrics as EnvironmentMetricsProto + +/** + * The per-channel 1-Wire and ADC readings are Wire-generated and nullable, so `null` is the only "no channel" signal. + * Both the absent and the measured-zero case are pinned — either alone lets the two states collapse back into one, + * which is how the old `repeated one_wire_temperature` reads used to lose channel identity. + */ +@OptIn(ExperimentalTestApi::class) +class EnvironmentChannelMetricsTest { + + @Test + fun zeroOneWireTemperatureIsShown() = runComposeUiTest { + setEnvironmentMetrics(EnvironmentMetricsProto(one_wire_temperature_ch0 = 0f)) + onNodeWithText("1-Wire Temp 1").assertIsDisplayed() + onNodeWithText("0°C").assertIsDisplayed() + } + + @Test + fun absentOneWireChannelIsHidden() = runComposeUiTest { + setEnvironmentMetrics(EnvironmentMetricsProto()) + onNodeWithText("1-Wire Temp 1").assertDoesNotExist() + } + + @Test + fun oneWireChannelsKeepTheirChannelNumber() = runComposeUiTest { + // A gap in the middle must not renumber the channels above it. + setEnvironmentMetrics(EnvironmentMetricsProto(one_wire_temperature_ch0 = 10f, one_wire_temperature_ch2 = 30f)) + onNodeWithText("1-Wire Temp 1").assertIsDisplayed() + onNodeWithText("1-Wire Temp 3").assertIsDisplayed() + onNodeWithText("1-Wire Temp 2").assertDoesNotExist() + } + + @Test + fun zeroAdcVoltageIsShown() = runComposeUiTest { + setEnvironmentMetrics(EnvironmentMetricsProto(adc_voltage_ch0 = 0f)) + onNodeWithText("ADC Voltage 1").assertIsDisplayed() + onNodeWithText("0.00 V").assertIsDisplayed() + } + + @Test + fun absentAdcChannelIsHidden() = runComposeUiTest { + setEnvironmentMetrics(EnvironmentMetricsProto()) + onNodeWithText("ADC Voltage 1").assertDoesNotExist() + } + + @Test + fun adcChannelsKeepTheirChannelNumber() = runComposeUiTest { + setEnvironmentMetrics(EnvironmentMetricsProto(adc_voltage_ch1 = 3.3f, adc_voltage_ch7 = 1.8f)) + onNodeWithText("ADC Voltage 2").assertIsDisplayed() + onNodeWithText("3.30 V").assertIsDisplayed() + onNodeWithText("ADC Voltage 8").assertIsDisplayed() + onNodeWithText("1.80 V").assertIsDisplayed() + } + + private fun ComposeUiTest.setEnvironmentMetrics(metrics: EnvironmentMetricsProto) = setContent { + MaterialTheme { + EnvironmentMetrics( + node = Node(num = 1, environmentMetrics = metrics), + displayUnits = Config.DisplayConfig.DisplayUnits.METRIC, + ) + } + } +}