feat: compute EPA NowCast AQI from PM2.5 history (#6102)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-05 21:27:51 -05:00
committed by GitHub
parent 4a9f85bf5e
commit 1dce09f0ae
9 changed files with 469 additions and 107 deletions

View File

@@ -93,6 +93,7 @@ app_settings
app_too_old
app_version
apply
aqi
are_you_sure
are_you_sure_change_default
audio

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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")

View File

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