mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-13 05:37:28 -04:00
fix(telemetry): repoint 1-Wire temperature at per-channel fields, adopt ADC voltage (#6653)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
bc7634bd81
commit
492afbde5c
21 files changed
+823
-140
No files matched your search
Generated
+2
@@ -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
|
||||
|
||||
+40
-17
@@ -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)
|
||||
}
|
||||
+50
@@ -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,
|
||||
|
||||
+18
@@ -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() }
|
||||
|
||||
|
||||
+20
@@ -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<Float>? = 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
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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
|
||||
}
|
||||
}
|
||||
+150
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
<string name="actions">Actions</string>
|
||||
<string name="adc_multiplier_override">ADC multiplier override</string>
|
||||
<string name="adc_multiplier_override_ratio">ADC multiplier override ratio</string>
|
||||
<string name="adc_voltage">ADC Voltage</string>
|
||||
<!-- ADD -->
|
||||
<string name="add">Add</string>
|
||||
<string name="add_a_note">Add a private note…</string>
|
||||
@@ -1126,6 +1127,7 @@
|
||||
<string name="message_status_unknown">Unknown</string>
|
||||
<string name="message_translated_label">Translated</string>
|
||||
<string name="messages">Messages</string>
|
||||
<string name="metric_channel_label">%1$s %2$d</string>
|
||||
<string name="micrograms_per_cubic_meter">µg/m³</string>
|
||||
<string name="min">Min</string>
|
||||
<string name="minimum_broadcast_seconds">Minimum broadcast (seconds)</string>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+36
-12
@@ -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)
|
||||
|
||||
+5
-1
@@ -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 ->
|
||||
|
||||
+10
-3
@@ -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,
|
||||
|
||||
+30
-35
@@ -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
|
||||
|
||||
+52
-24
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+80
-28
@@ -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<Environment> =
|
||||
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<Environment> =
|
||||
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<Telemetry> = 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<Telemetry> = 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 }
|
||||
|
||||
|
||||
+27
-17
@@ -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<Telemetry>) {
|
||||
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<org.meshtastic.proto.Position>, trackName: String): String {
|
||||
|
||||
@@ -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
|
||||
+14
@@ -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))
|
||||
|
||||
+69
@@ -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
|
||||
|
||||
+8
-3
@@ -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\",\"\",\"\",\"\",\"\",\"\",\"\"",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
+90
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user