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