feat(telemetry): ingest PM air-quality metrics + EPA NowCast AQI (#2040)#2052
feat(telemetry): ingest PM air-quality metrics + EPA NowCast AQI (#2040)#2052garthvh wants to merge 1 commit into
Conversation
Implements meshtastic/design#54 on iOS. AirQualityMetrics telemetry (PM1.0 / PM2.5 / PM10.0, standard + environmental) was previously received but discarded (only counted by the Discovery scan engine); it is now parsed in the main ingest path and persisted, and a NowCast AQI is computed from it. - Model: add pm10/25/100 standard + environmental fields to TelemetryEntity (optional -> automatic SwiftData lightweight migration). - Ingest: parse the AirQualityMetrics variant in MeshPackets.telemetryPacket and store as metricsType 3 (MetricsTypes.airQuality). - AirQualityCalculator: EPA NowCast (12h recency-weighted rolling average) + PM2.5 -> 0-500 AQI breakpoint math, ported 1:1 from the Android reference (Meshtastic-Android PR #6102) so both platforms compute identical values. Unit-tested with 10 cases mirroring the Android test vectors. - Display: new Air Quality Metrics Log (PM chart + table + CSV export) wired into NodeDetail. Shows the computed NowCast AQI via the existing AirQualityIndex view when enough recent history exists, otherwise the raw latest PM2.5 reading -- never a misleading instantaneous AQI (design#54). - CSV export includes the PM columns. Verified: xcodebuild test (iPhone 16 sim) builds the full app + test target and AirQualityCalculatorTests passes 10/10. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
📄 Docs staleness warningThis PR modifies user-facing Swift source files but does not update any page under Changed source files: What to check:
If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the After updating |
📝 WalkthroughWalkthroughAdds air quality (particulate matter) telemetry support: new TelemetryEntity fields, packet ingestion for airQualityMetrics, an EPA NowCast/AQI calculator with tests, NodeInfoEntity helper properties, a new AirQualityMetricsLog SwiftUI view with chart/table/export/clear, CSV export support, and NodeDetail navigation wiring. ChangesAir Quality Metrics
Estimated code review effort: 3 (Moderate) | ~30 minutes Possibly related issues
Sequence Diagram(s)sequenceDiagram
participant Device
participant MeshPackets
participant TelemetryEntity
participant NodeInfoEntity
participant AirQualityCalculator
participant AirQualityMetricsLog
Device->>MeshPackets: telemetryPacket(airQualityMetrics)
MeshPackets->>TelemetryEntity: persist PM fields, metricsType = 3
AirQualityMetricsLog->>NodeInfoEntity: refreshMetrics()
NodeInfoEntity->>TelemetryEntity: safeTelemetries(ofType: 3)
NodeInfoEntity->>AirQualityCalculator: computeNowCastPm25(readings)
AirQualityCalculator-->>NodeInfoEntity: nowCastPm25 or nil
NodeInfoEntity->>AirQualityCalculator: pm25ToAqi(nowCastPm25)
AirQualityCalculator-->>NodeInfoEntity: aqi
NodeInfoEntity-->>AirQualityMetricsLog: aqi or latestPm25
AirQualityMetricsLog-->>Device: render chart/table
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
Meshtastic/Views/Nodes/AirQualityMetricsLog.swift (1)
39-56: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winAvoid computing AQI/PM2.5 directly in
body.
node.currentNowCastAqi()(and thenode.latestPm25fallback) perform a SwiftData fetch plus NowCast computation every timebodyis evaluated, unlikeairQualityMetrics/chartData/totalReadings, which are cached viarefreshMetrics(). Sincebodycan re-evaluate frequently (bindable node changes, chart selection, etc.), this repeats an up-to-500-row fetch unnecessarily. Consider computing these once inrefreshMetrics()and storing them in@State, consistent with the rest of the view.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Views/Nodes/AirQualityMetricsLog.swift` around lines 39 - 56, The AirQualityMetricsLog view is still computing the NowCast AQI and PM2.5 fallback directly in body via node.currentNowCastAqi() and node.latestPm25, causing repeated SwiftData fetches on every re-render. Move this logic into refreshMetrics() alongside airQualityMetrics/chartData/totalReadings, store the resolved AQI and latest PM2.5 in `@State`, and have the body only read those cached values.Meshtastic/Helpers/MeshPackets.swift (1)
921-924: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider consolidating the variant allow-list with the handling switch below.
This guard duplicates the variant list that's re-checked via if/else at Line 945 onward. Each new
Telemetryvariant must be added in two places; missing one here silently drops the packet with no log. A singleswitch telemetryMessage.variant { case .deviceMetrics, .environmentMetrics, ...: ...; default: return }(or restructuring lines 945-1023 into aswitch) would remove the duplication and reduce the risk of a future variant being silently dropped.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Meshtastic/Helpers/MeshPackets.swift` around lines 921 - 924, The telemetry variant guard in MeshPackets should not duplicate the allow-list that is re-checked later in the telemetry handling logic. Update the `telemetryMessage.variant` filtering in the telemetry parsing path to use a single `switch` or otherwise consolidate the variant handling with the existing branch logic below, so every new `Telemetry.OneOf_Variant` is declared in one place. Keep the logic centered around the `telemetryMessage.variant` checks and the downstream handling block to avoid silently dropping supported packets.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift`:
- Around line 210-223: The NowCast AQI path in currentNowCastAqi is using
safeTelemetries(ofType:) and inherits its 500-row cap, which can truncate the
required 12-hour PM2.5 history. Change currentNowCastAqi to fetch the full
time-bounded telemetry window directly by timestamp instead of reusing the chart
helper, while still filtering for PM2.5 and mapping into the readings passed to
AirQualityCalculator.computeNowCastPm25. Keep the existing fallback behavior to
return nil when there is insufficient history.
In `@Meshtastic/Helpers/AirQualityCalculator.swift`:
- Around line 77-98: Update the PM2.5 AQI breakpoint table in
AirQualityCalculator’s breakpoints and pm25ToAqi logic to use the current EPA
thresholds instead of the outdated 2012 ranges. Replace the existing
concentration bands with the May 2024 values, then verify the linear
interpolation and clamping in pm25ToAqi still produce 0–500 AQI across the full
range, especially for elevated PM2.5 values.
In `@Meshtastic/Views/Nodes/AirQualityMetricsLog.swift`:
- Around line 104-127: The Table in AirQualityMetricsLog currently binds
sortOrder but never actually sorts airQualityMetrics, and the closure-based
TableColumn entries are not sortable as written. Update the Table setup to
either add sortable columns using value:/comparator: and handle onChange(of:
sortOrder) by re-sorting the data, or remove the sortOrder binding entirely if
sorting is not needed; use a custom comparator or manual sort logic for the
optional PM columns in the table.
In `@MeshtasticTests/AirQualityCalculatorTests.swift`:
- Around line 9-12: This new test file is using XCTest, but new tests in
MeshtasticTests should use Swift Testing instead. Update
AirQualityCalculatorTests to import Testing, replace XCTestCase with a `@Suite`
test container, and convert each test method to `@Test` using `#expect` and `#require`
where needed. Keep the test names and assertions aligned with the existing
AirQualityCalculator test cases while removing XCTest-specific setup.
---
Nitpick comments:
In `@Meshtastic/Helpers/MeshPackets.swift`:
- Around line 921-924: The telemetry variant guard in MeshPackets should not
duplicate the allow-list that is re-checked later in the telemetry handling
logic. Update the `telemetryMessage.variant` filtering in the telemetry parsing
path to use a single `switch` or otherwise consolidate the variant handling with
the existing branch logic below, so every new `Telemetry.OneOf_Variant` is
declared in one place. Keep the logic centered around the
`telemetryMessage.variant` checks and the downstream handling block to avoid
silently dropping supported packets.
In `@Meshtastic/Views/Nodes/AirQualityMetricsLog.swift`:
- Around line 39-56: The AirQualityMetricsLog view is still computing the
NowCast AQI and PM2.5 fallback directly in body via node.currentNowCastAqi() and
node.latestPm25, causing repeated SwiftData fetches on every re-render. Move
this logic into refreshMetrics() alongside
airQualityMetrics/chartData/totalReadings, store the resolved AQI and latest
PM2.5 in `@State`, and have the body only read those cached values.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c435f35b-7f86-415a-a36f-d7af495f0352
📒 Files selected for processing (9)
Meshtastic.xcodeproj/project.pbxprojMeshtastic/Export/WriteCsvFile.swiftMeshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swiftMeshtastic/Helpers/AirQualityCalculator.swiftMeshtastic/Helpers/MeshPackets.swiftMeshtastic/Model/TelemetryEntity.swiftMeshtastic/Views/Nodes/AirQualityMetricsLog.swiftMeshtastic/Views/Nodes/Helpers/NodeDetail.swiftMeshtasticTests/AirQualityCalculatorTests.swift
| /// The current EPA NowCast AQI (0–500) computed from this node's PM2.5 history, or `nil` when | ||
| /// there isn't enough recent history (per meshtastic/design#54). Callers should fall back to the | ||
| /// raw `latestPm25` reading rather than showing a misleading instantaneous value. | ||
| func currentNowCastAqi(now: Date = Date()) -> Int? { | ||
| let readings: [(time: TimeInterval, pm25: Double)] = safeTelemetries(ofType: 3).compactMap { telemetry in | ||
| guard let pm25 = telemetry.pm25Standard, let time = telemetry.time else { return nil } | ||
| return (time.timeIntervalSince1970, Double(pm25)) | ||
| } | ||
| guard let nowCast = AirQualityCalculator.computeNowCastPm25(readings: readings, nowEpochSeconds: now.timeIntervalSince1970) else { | ||
| return nil | ||
| } | ||
| return AirQualityCalculator.pm25ToAqi(nowCast) | ||
| } | ||
|
|
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map relevant files and symbols first
git ls-files 'Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift' \
'Meshtastic/Extensions/SwiftData/*' \
'Meshtastic/**/AirQualityCalculator*' \
'Meshtastic/**/Telemetry*' \
| sed -n '1,200p'
echo
echo '--- outline: NodeInfoEntityExtension.swift ---'
ast-grep outline Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift --view expanded || true
echo
echo '--- search safeTelemetries / computeNowCast ---'
rg -n "safeTelemetries|computeNowCastPm25|currentNowCastAqi|fetchLimit|12 \* 3600|NowCast" Meshtastic -SRepository: meshtastic/Meshtastic-Apple
Length of output: 15118
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the helper and nearby implementation with line numbers.
file='Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift'
wc -l "$file"
echo '--- excerpt 1 ---'
sed -n '1,260p' "$file" | cat -n
echo
echo '--- AirQualityCalculator search ---'
fd -a 'AirQualityCalculator*' Meshtastic || true
rg -n "func computeNowCastPm25|struct AirQualityCalculator|class AirQualityCalculator" Meshtastic -SRepository: meshtastic/Meshtastic-Apple
Length of output: 12103
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Find the TelemetryEntity definition and any constraints around time/type indexing.
rg -n "class TelemetryEntity|struct TelemetryEntity|`@Model`|metricsType|pm25Standard|time" Meshtastic/Extensions Meshtastic -g'*.swift' -S
# If a file is found, show a focused excerpt around TelemetryEntity.
file=$(fd -a 'TelemetryEntity*.swift' Meshtastic | head -n 1 || true)
if [ -n "${file:-}" ]; then
echo "--- file: $file ---"
wc -l "$file"
sed -n '1,260p' "$file" | cat -n
fiRepository: meshtastic/Meshtastic-Apple
Length of output: 50384
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Focused reads for the NowCast calculator and the telemetry helper.
sed -n '1,220p' Meshtastic/Helpers/AirQualityCalculator.swift | cat -n
echo
sed -n '260,290p' Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift | cat -nRepository: meshtastic/Meshtastic-Apple
Length of output: 7096
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Look for any tests/docs that define the NowCast window or history requirements.
rg -n "design#54|NowCast|nowCast|EPA requires|12-hour|12 hours|pm25ToAqi" Meshtastic -S
# Show any nearby test-like or documentation references if present.
fd -a '*NowCast*' Meshtastic || true
fd -a '*AirQuality*' Meshtastic || trueRepository: meshtastic/Meshtastic-Apple
Length of output: 4662
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the NowCast calculator implementation and inspect its history-window logic.
file='Meshtastic/Helpers/AirQualityCalculator.swift'
wc -l "$file"
echo '--- excerpt ---'
sed -n '1,220p' "$file" | cat -n
echo
echo '--- safeTelemetries excerpt ---'
sed -n '268,286p' Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift | cat -nRepository: meshtastic/Meshtastic-Apple
Length of output: 6635
Fetch the full 12-hour PM2.5 window here (Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift:213-218). safeTelemetries(ofType:) is capped at 500 rows, so nodes reporting faster than ~2–3 minutes can lose part of the NowCast history and return a skewed AQI. Query this path by time instead of reusing the chart helper.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift` around lines
210 - 223, The NowCast AQI path in currentNowCastAqi is using
safeTelemetries(ofType:) and inherits its 500-row cap, which can truncate the
required 12-hour PM2.5 history. Change currentNowCastAqi to fetch the full
time-bounded telemetry window directly by timestamp instead of reusing the chart
helper, while still filtering for PM2.5 and mapping into the readings passed to
AirQualityCalculator.computeNowCastPm25. Keep the existing fallback behavior to
return nil when there is insufficient history.
| // Standard EPA PM2.5 (µg/m³) breakpoint table. | ||
| private static let breakpoints: [Breakpoint] = [ | ||
| Breakpoint(concentrationLow: 0.0, concentrationHigh: 12.0, aqiLow: 0, aqiHigh: 50), | ||
| Breakpoint(concentrationLow: 12.1, concentrationHigh: 35.4, aqiLow: 51, aqiHigh: 100), | ||
| Breakpoint(concentrationLow: 35.5, concentrationHigh: 55.4, aqiLow: 101, aqiHigh: 150), | ||
| Breakpoint(concentrationLow: 55.5, concentrationHigh: 150.4, aqiLow: 151, aqiHigh: 200), | ||
| Breakpoint(concentrationLow: 150.5, concentrationHigh: 250.4, aqiLow: 201, aqiHigh: 300), | ||
| Breakpoint(concentrationLow: 250.5, concentrationHigh: 500.4, aqiLow: 301, aqiHigh: 500) | ||
| ] | ||
|
|
||
| /// Converts a PM2.5 concentration (µg/m³) to a 0–500 EPA AQI value via linear interpolation over | ||
| /// the standard breakpoint table. Negative inputs clamp to 0; concentrations above the top | ||
| /// breakpoint clamp to AQI 500. The result is always in `0...500`, safe to pass to `Aqi.getAqi(for:)`. | ||
| static func pm25ToAqi(_ concentration: Double) -> Int { | ||
| let clamped = max(concentration, 0) | ||
| let breakpoint = breakpoints.last(where: { clamped >= $0.concentrationLow }) ?? breakpoints[0] | ||
| if clamped > breakpoint.concentrationHigh { return breakpoint.aqiHigh } | ||
| let aqi = Double(breakpoint.aqiHigh - breakpoint.aqiLow) / | ||
| (breakpoint.concentrationHigh - breakpoint.concentrationLow) * | ||
| (clamped - breakpoint.concentrationLow) + Double(breakpoint.aqiLow) | ||
| return Int(aqi.rounded()) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== File outline ==\n'
ast-grep outline Meshtastic/Helpers/AirQualityCalculator.swift --view expanded || true
printf '\n== Relevant lines ==\n'
sed -n '1,180p' Meshtastic/Helpers/AirQualityCalculator.swift | cat -n
printf '\n== Search for PM2.5/AQI breakpoints in repo ==\n'
rg -n "concentrationHigh:|pm25ToAqi|AQI|PM2.5|breakpoints" Meshtastic -g '*.swift' || trueRepository: meshtastic/Meshtastic-Apple
Length of output: 10684
🌐 Web query:
AirNow EPA PM2.5 AQI breakpoints May 2024 technical assistance document
💡 Result:
The document you are looking for is titled Technical Assistance Document for the Reporting of Daily Air Quality - the Air Quality Index (AQI), published by the U.S. Environmental Protection Agency (EPA) in May 2024, with the reference number EPA-454/B-24-002 [1]. This document provides guidance for reporting daily air quality and incorporates the updated PM2.5 Air Quality Index (AQI) breakpoints that became effective on May 6, 2024 [2][1]. These updates were implemented to reflect a revised primary annual PM2.5 National Ambient Air Quality Standard (NAAQS) of 9.0 micrograms per cubic meter (µg/m³) and to align with contemporary health science regarding particulate matter [2][3]. The updated PM2.5 AQI breakpoints (in µg/m³) are as follows: Good (0–50): 0.0 to 9.0 [2][4] Moderate (51–100): 9.1 to 35.4 [2] Unhealthy for Sensitive Groups (101–150): 35.5 to 55.4 [2] Unhealthy (151–200): 55.5 to 125.4 [2] Very Unhealthy (201–300): 125.5 to 225.4 [2] Hazardous (301+): 225.5 and above [2] For the hazardous category, the EPA consolidated two previous breakpoints into a single range starting at 225.5 µg/m³ [2]. You can access the full technical assistance document through the EPA's National Service Center for Environmental Publications (NSCEP) or via the official AirNow website [5][1].
Citations:
- 1: https://nepis.epa.gov/Exe/ZyPURL.cgi?Dockey=P101AP0Q.TXT
- 2: https://www.epa.gov/system/files/documents/2024-02/pm-naaqs-air-quality-index-fact-sheet.pdf
- 3: https://www.epa.gov/system/files/documents/2024-02/pm-naaqs-final-frn-pre-publication_0.pdf
- 4: https://aqs.epa.gov/aqsweb/documents/codetables/aqi_breakpoints.html
- 5: https://www.airnow.gov/publications/air-quality-index/technical-assistance-document-for-reporting-the-daily-aqi/
🌐 Web query:
Meshtastic Android AirQualityCalculator PM2.5 AQI breakpoints
💡 Result:
Meshtastic does not currently implement a standard Air Quality Index (AQI) calculation for PM2.5 readings within its Android application or firmware, nor does it maintain internal "AirQualityCalculator" breakpoints for this purpose [1]. Key technical details regarding air quality data in Meshtastic: 1. Data Handling: Meshtastic firmware supports various particulate matter sensors (such as the PMSA003I) and can transmit raw PM2.5 and PM10 values via telemetry [2][1][3]. These raw values are included in the AirQualityMetrics protobuf definition [4]. However, official development discussions indicate that the Android application generally does not perform post-processing to convert these raw particulate readings into an AQI value, as this is a complex process often requiring standardized regional breakpoints (like those provided by the US EPA) [5][1]. 2. IAQ vs. AQI: The Meshtastic ecosystem differentiates between "Air Quality Index" (AQI) for particulate matter and "Indoor Air Quality" (IAQ). The firmware does include built-in logic and thresholds for IAQ, typically derived from sensors like the BME680 [6][7]. The firmware categorizes IAQ values into specific ranges (e.g., 0-25 Excellent, 26-50 Good, 51-100 Moderate, up to 300+ Hazardous) and may trigger alerts based on these calculations [6]. 3. Android App Status: Users have reported that while raw particulate data may be visible in logs or terminal outputs from sensor-equipped nodes, these values are not consistently displayed or converted to AQI within the Meshtastic Android application interface [1]. Efforts have been made to improve environment metrics displays, such as adding IAQ visualizations, but particulate-based AQI calculation remains distinct from these features [8][1].
Citations:
- 1: [Bug]: Particulate sensors not showing anything - even raw values. Meshtastic-Android#4188
- 2: https://github.com/meshtastic/firmware/blob/b2bda3b0/src/modules/Telemetry/AirQualityTelemetry.cpp
- 3: Add detection framework for multiple AirQuality sensors firmware#7187
- 4: https://jsr.io/@meshtastic/protobufs/doc/~/Telemetry
- 5: https://aqs.epa.gov/aqsweb/documents/codetables/aqi_breakpoints.html
- 6: https://github.com/meshtastic/firmware/blob/fb6d199d/src/modules/Telemetry/EnvironmentTelemetry.cpp
- 7: Fake IAQ values on Non-BSEC2 platforms like Platformio and the original ESP32 firmware#9663
- 8: feat: Add IAQ to environment metrics Meshtastic-Android#1258
🌐 Web query:
site:github.com Meshtastic-Android AirQualityCalculator pm25ToAqi breakpoints 12.0 35.4 55.4 150.4 250.4 500.4
💡 Result:
The breakpoints 12.0, 35.4, 55.4, 150.4, 250.4, and 500.4 are standard values used to calculate the United States Air Quality Index (AQI) from PM2.5 concentrations in micrograms per cubic meter (µg/m³) [1][2][3]. While these values are widely used in environmental monitoring software and DIY air quality projects (such as those using the US EPA's standard calculation methods) [1][3], there is no evidence of an official "AirQualityCalculator" class or specific PM2.5-to-AQI conversion implementation within the Meshtastic-Android codebase [4][5]. Meshtastic firmware currently transmits raw particulate matter sensor data (such as PM2.5) via telemetry packets [6][7]. As of July 2026, the Meshtastic-Android application focuses on displaying supported sensor data, but requests for native AQI calculation and specific particulate matter display support have been noted by the community as features that are not yet implemented [4]. Consequently, any implementation of these breakpoints in the context of Meshtastic would typically be a custom integration or external post-processing step performed by the end-user on the raw telemetry data [4][1].
Citations:
- 1: https://github.com/DPHacks/picow-air/blob/main/lib/dphacks_usaqi.py
- 2: https://github.com/HardjunoIndracahya/aqi-calculator
- 3: https://github.com/simonw/til/blob/main/purpleair/purple-air-aqi.md
- 4: [Bug]: Particulate sensors not showing anything - even raw values. Meshtastic-Android#4188
- 5: feat: Add IAQ to environment metrics Meshtastic-Android#1258
- 6: https://github.com/meshtastic/firmware/blob/b2bda3b0/src/modules/Telemetry/AirQualityTelemetry.cpp
- 7: [Bug]: RAK12039 Particulate Matter Sensor data is not added in MeshPacketSerializer firmware#4390
🌐 Web query:
site:github.com/meshtastic/Meshtastic-Android PR 6102 AirQualityCalculator PM2.5 AQI
💡 Result:
There is no public record or Pull Request #6102 related to an AirQualityCalculator for PM2.5 or AQI in the Meshtastic Android repository [1][2]. Current air quality support in the Meshtastic Android app is focused on displaying telemetry data—specifically PM1.0, PM2.5, PM10, and CO2—from supported sensors [3]. While users have requested more advanced features such as local AQI (Air Quality Index) calculation or raw data visualization for particulate sensors like the PMSA003I, official support for calculating AQI from raw PM2.5 values is not currently implemented [4]. Development efforts have primarily focused on displaying environment metrics, such as Indoor Air Quality (IAQ) from BME680 sensors, which was added in previous releases [5][6]. For context, recent development activity in the repository (as of July 2026) has been concentrated on other features, such as Mesh Beacon discovery (PR #6043) and TAK v2 protocol integration [7][8].
Citations:
- 1: meshtastic/Meshtastic-Android@v2.7.14...v2.8.0-internal.6
- 2: https://github.com/meshtastic/Meshtastic-Android
- 3: https://github.com/meshtastic/Meshtastic-Android/blob/main/README.md
- 4: [Bug]: Particulate sensors not showing anything - even raw values. Meshtastic-Android#4188
- 5: feat: Add IAQ to environment metrics Meshtastic-Android#1258
- 6: [Feature Request]: Clear display of IQA. Meshtastic-Android#1042
- 7: feat(discovery): surface received Mesh Beacon invitations Meshtastic-Android#6043
- 8: feat: TAK v2 protocol integration with zstd compression and full CoT type support Meshtastic-Android#5434
Update the PM2.5 AQI breakpoints to the current EPA values Meshtastic/Helpers/AirQualityCalculator.swift:77-98
Replace the table with the May 2024 EPA thresholds (0–9.0, 9.1–35.4, 35.5–55.4, 55.5–125.4, 125.5–225.4, 225.5–325.4); the current 2012 values understate AQI for elevated PM2.5 and no longer match the published US AQI standard.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Meshtastic/Helpers/AirQualityCalculator.swift` around lines 77 - 98, Update
the PM2.5 AQI breakpoint table in AirQualityCalculator’s breakpoints and
pm25ToAqi logic to use the current EPA thresholds instead of the outdated 2012
ranges. Replace the existing concentration bands with the May 2024 values, then
verify the linear interpolation and clamping in pm25ToAqi still produce 0–500
AQI across the full range, especially for elevated PM2.5 values.
| Table(airQualityMetrics, selection: $selection, sortOrder: $sortOrder) { | ||
| TableColumn("PM2.5") { m in | ||
| m.pm25Standard.map { Text("\($0) µg/m³") } ?? Text(Constants.nilValueIndicator) | ||
| } | ||
| .width(min: 90) | ||
| TableColumn("PM1.0") { m in | ||
| m.pm10Standard.map { Text("\($0) µg/m³") } ?? Text(Constants.nilValueIndicator) | ||
| } | ||
| .width(min: 90) | ||
| TableColumn("PM10.0") { m in | ||
| m.pm100Standard.map { Text("\($0) µg/m³") } ?? Text(Constants.nilValueIndicator) | ||
| } | ||
| .width(min: 90) | ||
| TableColumn("Timestamp") { m in | ||
| Text(m.time?.formatted(date: .numeric, time: .shortened) ?? "Unknown Age".localized) | ||
| } | ||
| .width(min: 180) | ||
| } | ||
| .onChange(of: selection) { _, newSelection in | ||
| guard let metrics = airQualityMetrics.first(where: { $0.id == newSelection }) else { | ||
| return | ||
| } | ||
| chartSelection = metrics.time | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## File listing\n'
git ls-files 'Meshtastic/Views/Nodes/*AirQualityMetricsLog.swift' 'Meshtastic/Views/Nodes/**/*AirQualityMetricsLog.swift' || true
printf '\n## Outline\n'
ast-grep outline Meshtastic/Views/Nodes/AirQualityMetricsLog.swift --view expanded || true
printf '\n## Relevant line range\n'
nl -ba Meshtastic/Views/Nodes/AirQualityMetricsLog.swift | sed -n '1,260p'
printf '\n## Search sortOrder usages\n'
rg -n 'sortOrder|onChange\(of: sortOrder|TableColumn\(' Meshtastic/Views/Nodes/AirQualityMetricsLog.swift Meshtastic/Views/Nodes -g '*.swift' || trueRepository: meshtastic/Meshtastic-Apple
Length of output: 439
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## AirQualityMetricsLog.swift (selected ranges)\n'
sed -n '1,240p' Meshtastic/Views/Nodes/AirQualityMetricsLog.swift
printf '\n## Search for sort-related handling in this file\n'
rg -n 'sortOrder|onChange|sort(using:|TableColumn' Meshtastic/Views/Nodes/AirQualityMetricsLog.swift
printf '\n## Search for the metric type definition\n'
rg -n 'struct .*Metric|class .*Metric|pm25Standard|pm10Standard|pm100Standard|time' Meshtastic -g '*.swift'Repository: meshtastic/Meshtastic-Apple
Length of output: 7768
🌐 Web query:
SwiftUI Table sortOrder automatic sorting TableColumn closure initializer sort value documentation
💡 Result:
In SwiftUI, the Table component does not perform automatic data sorting on its own [1]. When you provide a sortOrder binding to a Table, SwiftUI tracks the user's interaction with column headers, but the responsibility for actually reordering your data array remains with you [2][1]. To implement sorting in a SwiftUI Table: 1. Define a state property (typically an array of KeyPathComparator) to hold the current sort order [2][1][3]. 2. Pass a binding to this state property into the Table initializer via the sortOrder parameter [4][2]. 3. Ensure your TableColumns specify a value using a KeyPath [1][3]. This KeyPath allows the Table to associate the column with a property of your data model and is required for the column to be sortable [1]. 4. Monitor changes to the sortOrder state using the onChange modifier and update your data source (e.g., by calling sorted(using:) on your collection) [2][1][3]. Regarding the TableColumn closure initializer, you can use initializers that accept a comparator parameter if you need custom sorting logic for specific columns [5][2]. For example, the init(_:value:comparator:) initializer allows you to specify a custom SortComparator for a column that displays a string property [5]. If you are using Core Data with FetchRequest, you can sometimes bypass manual sorting by binding the table's sortOrder directly to the fetch request's sortDescriptors, which can handle the update automatically [6][7]. Top Results: [4] https://developer.apple.com/documentation/swiftui/table [5] https://developer.apple.com/documentation/swiftui/tablecolumn/init(_:value:comparator:)-yhkp [2] https://developer.apple.com/forums/thread/724039 [1] https://developer.apple.com/videos/play/wwdc2022/10058/?time=235 [3] https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-multi-column-lists-using-table [6] https://useyourloaf.com/blog/swiftui-tables-quick-guide/ [7] https://developer.apple.com/forums/thread/683548
Citations:
- 1: https://developer.apple.com/videos/play/wwdc2022/10058/?time=235
- 2: https://developer.apple.com/forums/thread/724039
- 3: https://www.hackingwithswift.com/quick-start/swiftui/how-to-create-multi-column-lists-using-table
- 4: https://developer.apple.com/documentation/swiftui/table
- 5: https://developer.apple.com/documentation/swiftui/tablecolumn/init(_:value:comparator:)-yhkp
- 6: https://useyourloaf.com/blog/swiftui-tables-quick-guide/
- 7: https://developer.apple.com/forums/thread/683548
Enable table sorting or remove sortOrder
Table won’t reorder airQualityMetrics by itself here, and these closure-only TableColumns aren’t sortable. Add value:/comparator: columns plus an onChange(of: sortOrder) that reapplies sort(using:), or drop the sortOrder binding if sorting isn’t intended. The PM columns use optionals, so they’ll need a custom comparator or manual sort closure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Meshtastic/Views/Nodes/AirQualityMetricsLog.swift` around lines 104 - 127,
The Table in AirQualityMetricsLog currently binds sortOrder but never actually
sorts airQualityMetrics, and the closure-based TableColumn entries are not
sortable as written. Update the Table setup to either add sortable columns using
value:/comparator: and handle onChange(of: sortOrder) by re-sorting the data, or
remove the sortOrder binding entirely if sorting is not needed; use a custom
comparator or manual sort logic for the optional PM columns in the table.
| import XCTest | ||
| @testable import Meshtastic | ||
|
|
||
| final class AirQualityCalculatorTests: XCTestCase { |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Use Swift Testing instead of XCTest for this new test file.
This is a new test file, but it uses XCTest/XCTestCase. Per coding guidelines, new tests under MeshtasticTests/**/*.swift must use Swift Testing (import Testing, @Suite, @Test, #expect, #require).
As per coding guidelines, "Use Swift Testing (import Testing, @Suite, @Test, #expect, #require) for all new tests; do not use XCTest for new test files."
♻️ Example conversion for a couple of test cases
-import XCTest
-@testable import Meshtastic
-
-final class AirQualityCalculatorTests: XCTestCase {
+import Testing
+@testable import Meshtastic
+
+@Suite("AirQualityCalculator")
+struct AirQualityCalculatorTests {
private let secondsPerHour: TimeInterval = 3600
private let now: TimeInterval = 1_000_000
private let epsilon = 0.001
// MARK: - pm25ToAqi
- func testPm25ToAqiMatchesEpaBreakpointTable() {
- XCTAssertEqual(AirQualityCalculator.pm25ToAqi(0.0), 0)
+ `@Test` func pm25ToAqiMatchesEpaBreakpointTable() {
+ `#expect`(AirQualityCalculator.pm25ToAqi(0.0) == 0)
...
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@MeshtasticTests/AirQualityCalculatorTests.swift` around lines 9 - 12, This
new test file is using XCTest, but new tests in MeshtasticTests should use Swift
Testing instead. Update AirQualityCalculatorTests to import Testing, replace
XCTestCase with a `@Suite` test container, and convert each test method to `@Test`
using `#expect` and `#require` where needed. Keep the test names and assertions
aligned with the existing AirQualityCalculator test cases while removing
XCTest-specific setup.
Source: Coding guidelines
|
Closing in favor of the staged pair #2074 (Stage 1 — ingest/persist/raw display) + #2075 (Stage 2 — NowCast EPA AQI), which together cover the same ground as this PR plus docs, localization, the DiscoveryScanEngine sensor-count fix, and metrics-columns integration. On the one substantive difference — the EPA breakpoint table — we're going with #2075's current (2024) EPA PM2.5 curve rather than this PR's 1:1 port of Android's legacy table. Rationale: adopt the current official standard on both platforms rather than freezing on the older curve to match. Android alignment tracked in meshtastic/Meshtastic-Android#6272, to land before this feature releases so the two platforms don't ship diverging curves. Thanks — the NowCast/breakpoint math here was a useful reference for confirming the two implementations agreed on everything except the table version. |
Summary
Implements the iOS side of the cross-platform AQI standard (meshtastic/design#54), closing #2040.
AirQualityMetricstelemetry (PM1.0 / PM2.5 / PM10.0, standard + environmental) was previously received but discarded — only counted by the Discovery scan engine, never persisted or shown. This ingests and persists it, and computes an EPA NowCast AQI from PM2.5 history.Ported 1:1 from the merged Android reference (Meshtastic-Android#6102) so both platforms produce identical AQI values.
Changes
Stage 1 — ingest & persist
TelemetryEntity: addpm10/25/100Standard+pm10/25/100Environmental(optional → automatic SwiftData lightweight migration).MeshPackets.telemetryPacket: parse theairQualityMetricsvariant and store asmetricsType 3(MetricsTypes.airQuality, which already existed).WriteCsvFile) gains a PM columns branch.Stage 2 — NowCast & display
AirQualityCalculator: EPA NowCast (12h recency-weighted rolling average) + PM2.5→0–500 AQI breakpoint math. Pure/testable; 10 unit tests mirror the Android test vectors exactly.NodeDetail.AirQualityIndexview when there's enough recent history; otherwise the raw latest PM2.5 reading — never a misleading instantaneous AQI (per design#54).Verification
xcodebuild teston the iPhone 16 simulator builds the full app + test target and runs the new suite:(Building the test target compiles the entire app target, so the whole change set compiles clean.) End-to-end display with live PM hardware hasn't been exercised on-device — worth a smoke test before release.
Notes
AirQualityIndexview /Aqienum were already present but had zero real call sites; this gives them their first caller, unchanged.Refs meshtastic/design#54 · aligns with Meshtastic-Android#6102
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes