From 1dce09f0ae8a9e19c58bd3974246e563250e0923 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:27:51 -0500 Subject: [PATCH] feat: compute EPA NowCast AQI from PM2.5 history (#6102) Co-authored-by: Claude Sonnet 5 --- .skills/compose-ui/strings-index.txt | 1 + .../core/model/util/AirQualityIndex.kt | 112 +++++++++++ .../core/model/util/AirQualityIndexTest.kt | 123 ++++++++++++ .../composeResources/values/strings.xml | 1 + .../core/ui/component/PmAqiSeverity.kt | 47 +++++ .../node/component/AirQualityMetrics.kt | 108 ++++++++--- .../feature/node/component/DeviceActions.kt | 3 + .../component/TelemetricActionsSection.kt | 180 ++++++++++-------- .../feature/node/detail/NodeDetailContent.kt | 1 + 9 files changed, 469 insertions(+), 107 deletions(-) create mode 100644 core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt create mode 100644 core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index f6deec545..512aad0cc 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -93,6 +93,7 @@ app_settings app_too_old app_version apply +aqi are_you_sure are_you_sure_change_default audio diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt new file mode 100644 index 000000000..3cbed9d7c --- /dev/null +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/AirQualityIndex.kt @@ -0,0 +1,112 @@ +/* + * 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 kotlin.math.pow + +/** + * EPA NowCast + AQI breakpoint math for PM2.5, per meshtastic/design#54. + * + * NowCast is a 12-hour rolling average of PM2.5 that weights recent hours more heavily than older ones, used in lieu of + * the official 24h EPA AQI average because it can report a value well before a full day of data exists. See + * https://usepa.servicenowservices.com/airnow (NowCast) and the EPA PM2.5 AQI breakpoint table. + */ +object AirQualityIndex { + + private const val NOWCAST_WINDOW_HOURS = 12 + private const val SECONDS_PER_HOUR = 3600L + + /** EPA requires the most recent hour plus at least 2 of the 3 most recent hours, or NowCast isn't reported. */ + private const val MIN_VALID_HOURS = 2 + private const val RECENT_WINDOW_HOURS = 3 + + private const val MIN_WEIGHT_FACTOR = 0.5 + + /** + * Computes the NowCast PM2.5 concentration (µg/m³) from a node's PM2.5 [readings] (epoch-seconds to µg/m³ pairs), + * relative to [nowEpochSeconds]. Readings are binned into hourly buckets (0 = most recent hour) and averaged within + * each bucket. Returns null if there isn't enough history yet: the most recent hour must have a reading, and at + * least [MIN_VALID_HOURS] of the [RECENT_WINDOW_HOURS] most recent hours must be populated — EPA's minimum-data + * rule, so stale data spread across the older end of the 12h window can't produce a value. + */ + fun computeNowCastPm25(readings: List>, nowEpochSeconds: Long): Double? { + val sums = DoubleArray(NOWCAST_WINDOW_HOURS) + val counts = IntArray(NOWCAST_WINDOW_HOURS) + for ((time, pm25) in readings) { + val hoursAgo = (nowEpochSeconds - time) / SECONDS_PER_HOUR + if (hoursAgo in 0 until NOWCAST_WINDOW_HOURS) { + sums[hoursAgo.toInt()] += pm25 + counts[hoursAgo.toInt()]++ + } + } + val hourlyAverages = List(NOWCAST_WINDOW_HOURS) { i -> if (counts[i] > 0) sums[i] / counts[i] else null } + val present = hourlyAverages.withIndex().mapNotNull { (i, v) -> v?.let { i to it } } + + val recentValid = hourlyAverages.take(RECENT_WINDOW_HOURS).count { it != null } + return if (hourlyAverages[0] == null || recentValid < MIN_VALID_HOURS) { + null + } else { + val max = present.maxOf { it.second } + val min = present.minOf { it.second } + val weightFactor = if (max <= 0.0) 1.0 else (1.0 - (max - min) / max).coerceAtLeast(MIN_WEIGHT_FACTOR) + + var weightedSum = 0.0 + var weightTotal = 0.0 + for ((hoursAgo, value) in present) { + val weight = weightFactor.pow(hoursAgo) + weightedSum += weight * value + weightTotal += weight + } + weightedSum / weightTotal + } + } + + private data class Breakpoint( + val concentrationLow: Double, + val concentrationHigh: Double, + val aqiLow: Int, + val aqiHigh: Int, + ) + + // Standard EPA PM2.5 (µg/m³) breakpoint table. + private val BREAKPOINTS = + listOf( + Breakpoint(0.0, 12.0, 0, 50), + Breakpoint(12.1, 35.4, 51, 100), + Breakpoint(35.5, 55.4, 101, 150), + Breakpoint(55.5, 150.4, 151, 200), + Breakpoint(150.5, 250.4, 201, 300), + Breakpoint(250.5, 500.4, 301, 500), + ) + + /** + * Converts a PM2.5 concentration (µg/m³) to a 0-500 EPA AQI value via linear interpolation over the standard + * breakpoint table. Concentrations above the top breakpoint are clamped to AQI 500. + */ + fun pm25ToAqi(concentration: Double): Int { + val clamped = concentration.coerceAtLeast(0.0) + val breakpoint = BREAKPOINTS.lastOrNull { clamped >= it.concentrationLow } ?: BREAKPOINTS.first() + if (clamped > breakpoint.concentrationHigh) return breakpoint.aqiHigh + val aqi = + (breakpoint.aqiHigh - breakpoint.aqiLow).toDouble() / + (breakpoint.concentrationHigh - breakpoint.concentrationLow) * (clamped - breakpoint.concentrationLow) + + breakpoint.aqiLow + return aqi.let { kotlin.math.round(it).toInt() } + } +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt new file mode 100644 index 000000000..ab3fc9196 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/AirQualityIndexTest.kt @@ -0,0 +1,123 @@ +/* + * 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 kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +private const val SECONDS_PER_HOUR = 3600L +private const val NOW = 1_000_000L +private const val EPSILON = 0.001 + +class AirQualityIndexTest { + + // EPA PM2.5 breakpoint table reference values (concentration -> AQI). + @Test + fun pm25ToAqi_matches_epa_breakpoint_table() { + assertEquals(0, AirQualityIndex.pm25ToAqi(0.0)) + assertEquals(50, AirQualityIndex.pm25ToAqi(12.0)) + assertEquals(51, AirQualityIndex.pm25ToAqi(12.1)) + assertEquals(100, AirQualityIndex.pm25ToAqi(35.4)) + assertEquals(101, AirQualityIndex.pm25ToAqi(35.5)) + assertEquals(150, AirQualityIndex.pm25ToAqi(55.4)) + assertEquals(151, AirQualityIndex.pm25ToAqi(55.5)) + assertEquals(200, AirQualityIndex.pm25ToAqi(150.4)) + assertEquals(201, AirQualityIndex.pm25ToAqi(150.5)) + assertEquals(300, AirQualityIndex.pm25ToAqi(250.4)) + assertEquals(301, AirQualityIndex.pm25ToAqi(250.5)) + assertEquals(500, AirQualityIndex.pm25ToAqi(500.4)) + } + + @Test + fun pm25ToAqi_clamps_negative_and_above_scale_concentrations() { + assertEquals(0, AirQualityIndex.pm25ToAqi(-5.0)) + assertEquals(500, AirQualityIndex.pm25ToAqi(1000.0)) + } + + @Test + fun computeNowCastPm25_returns_null_when_most_recent_hour_missing() { + // Only hour 1 (an hour ago) has data - EPA requires the most recent hour (c1) to be present. + val readings = listOf(NOW - SECONDS_PER_HOUR to 20.0) + assertNull(AirQualityIndex.computeNowCastPm25(readings, NOW)) + } + + @Test + fun computeNowCastPm25_returns_null_with_fewer_than_two_valid_hours() { + val readings = listOf(NOW to 20.0) + assertNull(AirQualityIndex.computeNowCastPm25(readings, NOW)) + } + + @Test + fun computeNowCastPm25_returns_null_when_second_valid_hour_is_outside_recent_three() { + // Two valid hours in the 12h window (now + 11h ago), but only one within the most recent 3 hours. + // EPA requires 2 of the 3 most recent hours, so this must not report a value. + val readings = listOf(NOW to 20.0, NOW - 11 * SECONDS_PER_HOUR to 20.0) + assertNull(AirQualityIndex.computeNowCastPm25(readings, NOW)) + } + + @Test + fun computeNowCastPm25_averages_stable_readings_with_weight_factor_one() { + // No variation across hours -> weight factor stays at 1, so NowCast is a plain average. + val readings = listOf(NOW to 20.0, NOW - SECONDS_PER_HOUR to 20.0, NOW - 2 * SECONDS_PER_HOUR to 20.0) + val result = AirQualityIndex.computeNowCastPm25(readings, NOW) + assertEquals(20.0, result!!, EPSILON) + } + + @Test + fun computeNowCastPm25_weights_recent_hours_more_heavily_when_declining() { + // c1=20 (now), c2=10 (1h ago). weightFactor = 1 - (20-10)/20 = 0.5 (also the EPA floor). + // NowCast = (20*1 + 10*0.5) / (1 + 0.5) = 25 / 1.5 + val readings = listOf(NOW to 20.0, NOW - SECONDS_PER_HOUR to 10.0) + val result = AirQualityIndex.computeNowCastPm25(readings, NOW) + assertEquals(25.0 / 1.5, result!!, EPSILON) + } + + @Test + fun computeNowCastPm25_applies_minimum_weight_factor_floor() { + // Range far exceeds max, so the raw weight factor would go deeply negative - it must floor at 0.5. + val readings = listOf(NOW to 100.0, NOW - SECONDS_PER_HOUR to 1.0) + val result = AirQualityIndex.computeNowCastPm25(readings, NOW) + // NowCast = (100*1 + 1*0.5) / 1.5 + assertEquals(100.5 / 1.5, result!!, EPSILON) + } + + @Test + fun computeNowCastPm25_averages_multiple_readings_within_the_same_hour() { + val readings = + listOf( + NOW to 10.0, + NOW - 60 to 30.0, // same hour bucket as above -> averages to 20.0 + NOW - SECONDS_PER_HOUR to 20.0, + ) + val result = AirQualityIndex.computeNowCastPm25(readings, NOW) + // Both hourly buckets average to 20.0 -> stable, so NowCast is 20.0 regardless of weighting. + assertEquals(20.0, result!!, EPSILON) + } + + @Test + fun computeNowCastPm25_ignores_readings_older_than_the_twelve_hour_window() { + val readings = listOf(NOW to 20.0, NOW - SECONDS_PER_HOUR to 20.0, NOW - 13 * SECONDS_PER_HOUR to 500.0) + val result = AirQualityIndex.computeNowCastPm25(readings, NOW) + assertEquals(20.0, result!!, EPSILON) + } + + private fun assertEquals(expected: Double, actual: Double, epsilon: Double) { + kotlin.test.assertTrue(abs(expected - actual) < epsilon, "expected $expected but was $actual") + } +} diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index d88c99a71..380fc300c 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -111,6 +111,7 @@ Application update required Version Apply + AQI Are you sure? Are you sure you want to change to the default channel? Audio diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt new file mode 100644 index 000000000..5ec22ba7a --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/PmAqiSeverity.kt @@ -0,0 +1,47 @@ +/* + * 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.ui.component + +import androidx.compose.ui.graphics.Color + +/** + * EPA AQI severity categories for PM2.5-derived AQI (0-500), per meshtastic/design#54. Mirrors [Co2Severity]'s + * ppm→severity pattern, keyed on AQI value instead. + */ +@Suppress("MagicNumber") +enum class PmAqiSeverity(val color: Color, val label: String, val range: IntRange) { + GOOD(Color(0xFF00E400), "Good", 0..50), + MODERATE(Color(0xFFFFFF00), "Moderate", 51..100), + UNHEALTHY_SENSITIVE(Color(0xFFFF7E00), "Unhealthy for Sensitive Groups", 101..150), + UNHEALTHY(Color(0xFFFF0000), "Unhealthy", 151..200), + VERY_UNHEALTHY(Color(0xFF8F3F97), "Very Unhealthy", 201..300), + HAZARDOUS(Color(0xFF7E0023), "Hazardous", 301..Int.MAX_VALUE), + ; + + companion object { + /** Returns the [PmAqiSeverity] for the given 0-500 EPA [aqi] value, or null if negative. */ + fun fromAqi(aqi: Int): PmAqiSeverity? = when { + aqi < 0 -> null + aqi <= GOOD.range.last -> GOOD + aqi <= MODERATE.range.last -> MODERATE + aqi <= UNHEALTHY_SENSITIVE.range.last -> UNHEALTHY_SENSITIVE + aqi <= UNHEALTHY.range.last -> UNHEALTHY + aqi <= VERY_UNHEALTHY.range.last -> VERY_UNHEALTHY + else -> HAZARDOUS + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt index 18a640156..14d636d84 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt @@ -21,10 +21,17 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.nowSeconds import org.meshtastic.core.model.Node +import org.meshtastic.core.model.util.AirQualityIndex import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.aqi import org.meshtastic.core.resources.co2 import org.meshtastic.core.resources.micrograms_per_cubic_meter import org.meshtastic.core.resources.pm10 @@ -32,40 +39,101 @@ import org.meshtastic.core.resources.pm1_0 import org.meshtastic.core.resources.pm2_5 import org.meshtastic.core.resources.ppm import org.meshtastic.core.ui.component.Co2Severity +import org.meshtastic.core.ui.component.PmAqiSeverity import org.meshtastic.core.ui.icon.AirQuality import org.meshtastic.core.ui.icon.MeshtasticIcons import org.meshtastic.feature.node.model.VectorMetricInfo +import org.meshtastic.proto.AirQualityMetrics +import org.meshtastic.proto.Telemetry + +/** Computes the EPA NowCast AQI (value + severity) from [pm25History], or null if there isn't enough data yet. */ +private fun nowCastAqi(pm25History: List): Pair? { + val readings = + pm25History.mapNotNull { telemetry -> + telemetry.air_quality_metrics?.pm25_standard?.let { telemetry.time.toLong() to it.toDouble() } + } + val nowCastPm25 = AirQualityIndex.computeNowCastPm25(readings, nowSeconds) ?: return null + val aqiValue = AirQualityIndex.pm25ToAqi(nowCastPm25) + return PmAqiSeverity.fromAqi(aqiValue)?.let { aqiValue to it } +} + +private fun buildAirQualityCards( + metrics: AirQualityMetrics, + aqi: Pair?, + ugm3: String, + ppmUnit: String, + icon: ImageVector, + pm10Label: StringResource, + pm25Label: StringResource, + aqiLabel: StringResource, + pm100Label: StringResource, + co2Label: StringResource, +): List = buildList { + // A present reading of 0 is a valid value (e.g. clean air at 0 µg/m³), so only the `?.` null-check (an + // absent metric) hides a card — matching the #5793 chart/CSV zero-suppression fix. + metrics.pm10_standard?.let { pm -> add(VectorMetricInfo(pm10Label, "$pm $ugm3", icon)) } + metrics.pm25_standard?.let { pm -> + add(VectorMetricInfo(pm25Label, "$pm $ugm3", icon)) + // AQI sits alongside the raw PM2.5 reading, so only show it when that raw reading is present. + aqi?.let { (aqiValue, severity) -> add(VectorMetricInfo(aqiLabel, "$aqiValue (${severity.label})", icon)) } + } + metrics.pm100_standard?.let { pm -> add(VectorMetricInfo(pm100Label, "$pm $ugm3", icon)) } + metrics.co2?.let { co2 -> add(VectorMetricInfo(co2Label, "$co2 $ppmUnit", icon)) } +} + +private fun metricValueColor( + label: StringResource, + co2Color: Color?, + aqiSeverity: PmAqiSeverity?, + defaultColor: Color, +): Color = when (label) { + Res.string.co2 -> co2Color + Res.string.aqi -> aqiSeverity?.color + else -> null +} ?: defaultColor /** * Displays air quality info cards for a node showing PM1.0, PM2.5, PM10 and CO₂ values. A card is shown for each metric * the node actually reports; a present reading of 0 (e.g. clean air at 0 µg/m³) is a valid value and is shown — only * absent metrics are hidden. CO₂ value text is color-coded by severity. + * + * When [pm25History] has enough recent readings for an EPA NowCast (design#54), an additional AQI card is shown + * alongside the raw PM2.5 card, color-coded by EPA severity category. Below that threshold, only the raw readings are + * shown — never a computed AQI from insufficient data. */ @Composable -internal fun AirQualityInfoCards(node: Node) { +internal fun AirQualityInfoCards(node: Node, pm25History: List = emptyList()) { val metrics = node.airQualityMetrics val ugm3 = stringResource(Res.string.micrograms_per_cubic_meter) val ppmUnit = stringResource(Res.string.ppm) - val cards = buildList { - // A present reading of 0 is a valid value (e.g. clean air at 0 µg/m³), so only the `?.` null-check (an - // absent metric) hides a card — matching the #5793 chart/CSV zero-suppression fix. - metrics.pm10_standard?.let { pm -> - add(VectorMetricInfo(Res.string.pm1_0, "$pm $ugm3", MeshtasticIcons.AirQuality)) + // Not remembered on pm25History alone: NowCast depends on nowSeconds, so a value cached until new telemetry + // arrives would keep showing after its most recent reading ages out of the 12h window. Recomputing per + // recomposition (cheap) reads fresh nowSeconds; the equal-by-value Pair keeps `cards` below from churning. + // ponytail: no dedicated wall-clock ticker — the node-detail screen already stops recomposing with the node. + val aqi = nowCastAqi(pm25History) + val icon = MeshtasticIcons.AirQuality + val cards = + remember(metrics, aqi, ugm3, ppmUnit, icon) { + buildAirQualityCards( + metrics, + aqi, + ugm3, + ppmUnit, + icon, + Res.string.pm1_0, + Res.string.pm2_5, + Res.string.aqi, + Res.string.pm10, + Res.string.co2, + ) } - metrics.pm25_standard?.let { pm -> - add(VectorMetricInfo(Res.string.pm2_5, "$pm $ugm3", MeshtasticIcons.AirQuality)) - } - metrics.pm100_standard?.let { pm -> - add(VectorMetricInfo(Res.string.pm10, "$pm $ugm3", MeshtasticIcons.AirQuality)) - } - metrics.co2?.let { co2 -> add(VectorMetricInfo(Res.string.co2, "$co2 $ppmUnit", MeshtasticIcons.AirQuality)) } - } if (cards.isEmpty()) return - val co2Value = metrics.co2 ?: 0 - val co2Color = Co2Severity.fromPpm(co2Value)?.color + val co2Color = Co2Severity.fromPpm(metrics.co2 ?: 0)?.color + val aqiSeverity = aqi?.second + val defaultColor = MaterialTheme.colorScheme.onSurface FlowRow( modifier = Modifier.fillMaxWidth(), @@ -73,17 +141,11 @@ internal fun AirQualityInfoCards(node: Node) { verticalArrangement = Arrangement.SpaceEvenly, ) { cards.forEach { metric -> - val valueColor = - if (metric.label == Res.string.co2 && co2Color != null) { - co2Color - } else { - MaterialTheme.colorScheme.onSurface - } InfoCard( icon = metric.icon, text = stringResource(metric.label), value = metric.value, - valueColor = valueColor, + valueColor = metricValueColor(metric.label, co2Color, aqiSeverity, defaultColor), ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt index 1e7e971b8..e944bab58 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/DeviceActions.kt @@ -61,6 +61,7 @@ import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.feature.node.model.isEffectivelyUnmessageable import org.meshtastic.proto.Config +import org.meshtastic.proto.Telemetry @Composable fun DeviceActions( @@ -74,6 +75,7 @@ fun DeviceActions( isFahrenheit: Boolean, modifier: Modifier = Modifier, isLocal: Boolean = false, + airQualityHistory: List = emptyList(), ) { Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { SectionCard(title = Res.string.actions) { @@ -95,6 +97,7 @@ fun DeviceActions( isFahrenheit = isFahrenheit, onAction = onAction, isLocal = isLocal, + airQualityHistory = airQualityHistory, ) } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index 1698d6fb8..462cf3653 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -63,6 +63,7 @@ import org.meshtastic.core.ui.icon.Refresh import org.meshtastic.feature.node.model.LogsType import org.meshtastic.feature.node.model.NodeDetailAction import org.meshtastic.proto.Config +import org.meshtastic.proto.Telemetry private data class TelemetricFeature( val titleRes: StringResource, @@ -87,6 +88,7 @@ internal fun TelemetricActionsSection( isFahrenheit: Boolean, onAction: (NodeDetailAction) -> Unit, isLocal: Boolean = false, + airQualityHistory: List = emptyList(), ) { val features = rememberTelemetricFeatures( @@ -97,6 +99,7 @@ internal fun TelemetricActionsSection( displayUnits, isFahrenheit, isLocal, + airQualityHistory, ) SectionCard(title = Res.string.telemetry) { @@ -128,90 +131,99 @@ private fun rememberTelemetricFeatures( displayUnits: Config.DisplayConfig.DisplayUnits, isFahrenheit: Boolean, isLocal: Boolean, -): List = - remember(node, ourNode, lastTracerouteTime, lastRequestNeighborsTime, displayUnits, isFahrenheit, isLocal) { - listOf( - TelemetricFeature( - titleRes = Res.string.userinfo, - icon = Res.drawable.ic_person, - requestAction = { NodeMenuAction.RequestUserInfo(it) }, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.TRACEROUTE.titleRes, - icon = LogsType.TRACEROUTE.icon, - requestAction = { NodeMenuAction.TraceRoute(it) }, - logsType = LogsType.TRACEROUTE, - cooldownTimestamp = lastTracerouteTime, - isVisible = { !isLocal }, - ), - TelemetricFeature( - titleRes = LogsType.NEIGHBOR_INFO.titleRes, - icon = LogsType.NEIGHBOR_INFO.icon, - requestAction = { NodeMenuAction.RequestNeighborInfo(it) }, - logsType = LogsType.NEIGHBOR_INFO, - isVisible = { it.capabilities.canRequestNeighborInfo }, - cooldownTimestamp = lastRequestNeighborsTime, - cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS, - ), - TelemetricFeature( - titleRes = LogsType.SIGNAL.titleRes, - icon = LogsType.SIGNAL.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, - logsType = LogsType.SIGNAL, - ), - TelemetricFeature( - titleRes = LogsType.DEVICE.titleRes, - icon = LogsType.DEVICE.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, - logsType = LogsType.DEVICE, - ), - TelemetricFeature( - titleRes = LogsType.ENVIRONMENT.titleRes, - icon = Res.drawable.ic_thermostat, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) }, - logsType = LogsType.ENVIRONMENT, - content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) }, - hasContent = { it.hasEnvironmentMetrics }, - ), - TelemetricFeature( - titleRes = Res.string.request_air_quality_metrics, - icon = Res.drawable.ic_air, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, - logsType = LogsType.AIR_QUALITY, - content = { node, _ -> AirQualityInfoCards(node) }, - hasContent = { it.hasAirQualityMetrics }, - ), - TelemetricFeature( - titleRes = LogsType.POWER.titleRes, - icon = LogsType.POWER.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) }, - logsType = LogsType.POWER, - content = { node, _ -> PowerMetrics(node) }, - hasContent = { it.hasPowerMetrics }, - ), - TelemetricFeature( - titleRes = LogsType.HOST.titleRes, - icon = LogsType.HOST.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) }, - logsType = LogsType.HOST, - ), - TelemetricFeature( - titleRes = LogsType.PAX.titleRes, - icon = LogsType.PAX.icon, - requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, - logsType = LogsType.PAX, - ), - TelemetricFeature( - titleRes = LogsType.POSITIONS.titleRes, - icon = LogsType.POSITIONS.icon, - requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, - logsType = LogsType.POSITIONS, - content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, - hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, - ), - ) - } + airQualityHistory: List, +): List = remember( + node, + ourNode, + lastTracerouteTime, + lastRequestNeighborsTime, + displayUnits, + isFahrenheit, + isLocal, + airQualityHistory, +) { + listOf( + TelemetricFeature( + titleRes = Res.string.userinfo, + icon = Res.drawable.ic_person, + requestAction = { NodeMenuAction.RequestUserInfo(it) }, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.TRACEROUTE.titleRes, + icon = LogsType.TRACEROUTE.icon, + requestAction = { NodeMenuAction.TraceRoute(it) }, + logsType = LogsType.TRACEROUTE, + cooldownTimestamp = lastTracerouteTime, + isVisible = { !isLocal }, + ), + TelemetricFeature( + titleRes = LogsType.NEIGHBOR_INFO.titleRes, + icon = LogsType.NEIGHBOR_INFO.icon, + requestAction = { NodeMenuAction.RequestNeighborInfo(it) }, + logsType = LogsType.NEIGHBOR_INFO, + isVisible = { it.capabilities.canRequestNeighborInfo }, + cooldownTimestamp = lastRequestNeighborsTime, + cooldownDuration = REQUEST_NEIGHBORS_COOL_DOWN_TIME_MS, + ), + TelemetricFeature( + titleRes = LogsType.SIGNAL.titleRes, + icon = LogsType.SIGNAL.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.LOCAL_STATS) }, + logsType = LogsType.SIGNAL, + ), + TelemetricFeature( + titleRes = LogsType.DEVICE.titleRes, + icon = LogsType.DEVICE.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.DEVICE) }, + logsType = LogsType.DEVICE, + ), + TelemetricFeature( + titleRes = LogsType.ENVIRONMENT.titleRes, + icon = Res.drawable.ic_thermostat, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.ENVIRONMENT) }, + logsType = LogsType.ENVIRONMENT, + content = { node, _ -> EnvironmentMetrics(node, displayUnits, isFahrenheit) }, + hasContent = { it.hasEnvironmentMetrics }, + ), + TelemetricFeature( + titleRes = Res.string.request_air_quality_metrics, + icon = Res.drawable.ic_air, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, + logsType = LogsType.AIR_QUALITY, + content = { node, _ -> AirQualityInfoCards(node, airQualityHistory) }, + hasContent = { it.hasAirQualityMetrics }, + ), + TelemetricFeature( + titleRes = LogsType.POWER.titleRes, + icon = LogsType.POWER.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.POWER) }, + logsType = LogsType.POWER, + content = { node, _ -> PowerMetrics(node) }, + hasContent = { it.hasPowerMetrics }, + ), + TelemetricFeature( + titleRes = LogsType.HOST.titleRes, + icon = LogsType.HOST.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.HOST) }, + logsType = LogsType.HOST, + ), + TelemetricFeature( + titleRes = LogsType.PAX.titleRes, + icon = LogsType.PAX.icon, + requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.PAX) }, + logsType = LogsType.PAX, + ), + TelemetricFeature( + titleRes = LogsType.POSITIONS.titleRes, + icon = LogsType.POSITIONS.icon, + requestAction = if (isLocal) null else { n -> NodeMenuAction.RequestPosition(n) }, + logsType = LogsType.POSITIONS, + content = { node, action -> PositionInlineContent(node, ourNode, displayUnits, action) }, + hasContent = { it.latitude != 0.0 || it.longitude != 0.0 }, + ), + ) +} @OptIn(ExperimentalMaterial3Api::class) @Suppress("LongMethod") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt index 357e0eca3..f3c94df51 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailContent.kt @@ -124,6 +124,7 @@ fun NodeDetailList( displayUnits = uiState.metricsState.displayUnits, isFahrenheit = uiState.metricsState.isFahrenheit, isLocal = uiState.metricsState.isLocal, + airQualityHistory = uiState.metricsState.airQualityMetrics, ) } item { NotesSection(node = node, onSaveNotes = onSaveNotes) }