mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 12:26:23 -04:00
feat: compute EPA NowCast AQI from PM2.5 history (#6102)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1
.skills/compose-ui/strings-index.txt
generated
1
.skills/compose-ui/strings-index.txt
generated
@@ -93,6 +93,7 @@ app_settings
|
||||
app_too_old
|
||||
app_version
|
||||
apply
|
||||
aqi
|
||||
are_you_sure
|
||||
are_you_sure_change_default
|
||||
audio
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
@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<Pair<Long, Double>>, 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() }
|
||||
}
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@
|
||||
<string name="app_too_old">Application update required</string>
|
||||
<string name="app_version">Version</string>
|
||||
<string name="apply">Apply</string>
|
||||
<string name="aqi">AQI</string>
|
||||
<string name="are_you_sure">Are you sure?</string>
|
||||
<string name="are_you_sure_change_default">Are you sure you want to change to the default channel?</string>
|
||||
<string name="audio">Audio</string>
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Telemetry>): Pair<Int, PmAqiSeverity>? {
|
||||
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<Int, PmAqiSeverity>?,
|
||||
ugm3: String,
|
||||
ppmUnit: String,
|
||||
icon: ImageVector,
|
||||
pm10Label: StringResource,
|
||||
pm25Label: StringResource,
|
||||
aqiLabel: StringResource,
|
||||
pm100Label: StringResource,
|
||||
co2Label: StringResource,
|
||||
): List<VectorMetricInfo> = 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<Telemetry> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Telemetry> = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Telemetry> = 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<TelemetricFeature> =
|
||||
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<Telemetry>,
|
||||
): List<TelemetricFeature> = 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")
|
||||
|
||||
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user