Skip to content

feat(telemetry): ingest PM air-quality metrics + EPA NowCast AQI (#2040)#2052

Closed
garthvh wants to merge 1 commit into
mainfrom
feat/aqi-nowcast-2040
Closed

feat(telemetry): ingest PM air-quality metrics + EPA NowCast AQI (#2040)#2052
garthvh wants to merge 1 commit into
mainfrom
feat/aqi-nowcast-2040

Conversation

@garthvh

@garthvh garthvh commented Jul 6, 2026

Copy link
Copy Markdown
Member

Summary

Implements the iOS side of the cross-platform AQI standard (meshtastic/design#54), closing #2040. AirQualityMetrics telemetry (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: add pm10/25/100Standard + pm10/25/100Environmental (optional → automatic SwiftData lightweight migration).
  • MeshPackets.telemetryPacket: parse the airQualityMetrics variant and store as metricsType 3 (MetricsTypes.airQuality, which already existed).
  • CSV export (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.
  • New Air Quality Metrics Log (PM2.5/PM10 chart + table + CSV export + clear), wired into NodeDetail.
  • Shows the computed NowCast AQI via the existing AirQualityIndex view when there's enough recent history; otherwise the raw latest PM2.5 reading — never a misleading instantaneous AQI (per design#54).

Verification

xcodebuild test on the iPhone 16 simulator builds the full app + test target and runs the new suite:

Test Suite 'AirQualityCalculatorTests' passed
Executed 10 tests, with 0 failures
** TEST SUCCEEDED **

(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

  • The AirQualityIndex view / Aqi enum were already present but had zero real call sites; this gives them their first caller, unchanged.
  • Follows the issue's staged approach — raw readings are useful immediately; NowCast refines once ~history accrues.

Refs meshtastic/design#54 · aligns with Meshtastic-Android#6102

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added air quality metrics support for nodes, including a new log view, chart, table, and CSV export.
    • Display now includes an AQI estimate or the latest PM2.5 reading when available.
    • Air quality logs can be cleared from the app with confirmation.
  • Bug Fixes

    • Air quality telemetry is now recognized, stored, and included in log availability.
    • CSV exports now include air quality readings with timestamps.

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

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

📄 Docs staleness warning

This PR modifies user-facing Swift source files but does not update any page under docs/user/ or docs/developer/.

Changed source files:

Meshtastic/Model/TelemetryEntity.swift
Meshtastic/Views/Nodes/AirQualityMetricsLog.swift
Meshtastic/Views/Nodes/Helpers/NodeDetail.swift

What to check:

Changed area Likely doc page
Views/Messages/ docs/user/messages.md
Views/Nodes/ docs/user/nodes.md
Views/Map/ docs/user/map.md
Views/Settings/Bluetooth/ docs/user/bluetooth.md
Views/Settings/Discovery/ docs/user/discovery.md
Views/Settings/MQTT/ docs/user/mqtt.md
Views/Settings/TAK/ docs/user/tak.md
Views/Settings/Firmware/ docs/user/firmware.md
Views/Settings/ (telemetry/sensor) docs/user/telemetry.md
Views/Settings/ (general) docs/user/settings.md
Meshtastic Watch App/ docs/user/watch.md
Model/ docs/developer/swiftdata.md or docs/developer/architecture.md
Accessory/Transports/ docs/developer/transport.md

If this PR does not require a doc update (e.g., internal refactor, bug fix, test change), add the skip-docs-check label to dismiss this warning.

After updating docs/, re-run bash scripts/build-docs.sh --output Meshtastic/Resources/docs locally and commit the regenerated HTML bundle.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds 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.

Changes

Air Quality Metrics

Layer / File(s) Summary
Telemetry model and packet ingestion
Meshtastic/Model/TelemetryEntity.swift, Meshtastic/Helpers/MeshPackets.swift
Adds PM10/PM2.5/PM100 standard and environmental fields to TelemetryEntity and persists them from airQualityMetrics packets with metricsType = 3.
EPA NowCast and AQI calculator
Meshtastic/Helpers/AirQualityCalculator.swift, MeshtasticTests/AirQualityCalculatorTests.swift
Implements hourly bucketing, EPA minimum-data validation, weighted NowCast averaging, and PM2.5-to-AQI breakpoint interpolation, validated by a new unit test suite.
Node data helpers
Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift
Adds hasAirQualityMetrics, latestPm25, and currentNowCastAqi(now:) to NodeInfoEntity using the new calculator.
Air quality log view and CSV export
Meshtastic/Views/Nodes/AirQualityMetricsLog.swift, Meshtastic/Export/WriteCsvFile.swift
Adds a SwiftUI log view showing NowCast AQI or latest PM2.5, a PM chart and table, clear-log and CSV export actions, and a metricsType == 3 CSV export branch.
Node detail navigation wiring
Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
Adds hasAirQualityMetrics availability tracking and a conditional navigation link to the new log view.
Xcode project build wiring
Meshtastic.xcodeproj/project.pbxproj
Registers new AirQuality source and test files in build phases, file references, and groups.

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
Loading

Poem

A hop, a sniff, a puff of air,
I test the wind with gentle care,
PM2.5, NowCast glow,
AQI numbers start to show,
Clear the log, export with glee,
Fresh air metrics, just for thee! 🐰💨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: ingesting PM air-quality telemetry and adding EPA NowCast AQI support.
Description check ✅ Passed It includes the change summary, motivation, and test verification, but it omits the template's checklist section.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (2)
Meshtastic/Views/Nodes/AirQualityMetricsLog.swift (1)

39-56: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid computing AQI/PM2.5 directly in body.

node.currentNowCastAqi() (and the node.latestPm25 fallback) perform a SwiftData fetch plus NowCast computation every time body is evaluated, unlike airQualityMetrics/chartData/totalReadings, which are cached via refreshMetrics(). Since body can re-evaluate frequently (bindable node changes, chart selection, etc.), this repeats an up-to-500-row fetch unnecessarily. Consider computing these once in refreshMetrics() 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 win

Consider 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 Telemetry variant must be added in two places; missing one here silently drops the packet with no log. A single switch telemetryMessage.variant { case .deviceMetrics, .environmentMetrics, ...: ...; default: return } (or restructuring lines 945-1023 into a switch) 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

📥 Commits

Reviewing files that changed from the base of the PR and between 4148979 and d662ac2.

📒 Files selected for processing (9)
  • Meshtastic.xcodeproj/project.pbxproj
  • Meshtastic/Export/WriteCsvFile.swift
  • Meshtastic/Extensions/SwiftData/NodeInfoEntityExtension.swift
  • Meshtastic/Helpers/AirQualityCalculator.swift
  • Meshtastic/Helpers/MeshPackets.swift
  • Meshtastic/Model/TelemetryEntity.swift
  • Meshtastic/Views/Nodes/AirQualityMetricsLog.swift
  • Meshtastic/Views/Nodes/Helpers/NodeDetail.swift
  • MeshtasticTests/AirQualityCalculatorTests.swift

Comment on lines +210 to +223
/// 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)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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 -S

Repository: 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 -S

Repository: 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
fi

Repository: 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 -n

Repository: 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 || true

Repository: 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 -n

Repository: 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.

Comment on lines +77 to +98
// 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())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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' || true

Repository: 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:


🌐 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:


🌐 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:


🌐 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:


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.

Comment on lines +104 to +127
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
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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' || true

Repository: 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:


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.

Comment on lines +9 to +12
import XCTest
@testable import Meshtastic

final class AirQualityCalculatorTests: XCTestCase {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 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

@garthvh

garthvh commented Jul 15, 2026

Copy link
Copy Markdown
Member Author

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.

@garthvh garthvh closed this Jul 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant