[Spec Kit] Add air quality telemetry specification

This commit is contained in:
James Rich
2026-06-01 08:15:00 -05:00
parent d264b40862
commit 247e404ca4
8 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,38 @@
# Specification Quality Checklist: Air Quality Telemetry Display
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2025-06-01
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
- Upstream design decisions from design/issues/51 and design/issues/53 incorporated directly into spec.
- CO₂ color-coded thresholds specified per Oscar's guidance.
- Chart style guidance (thin lines, dot at cursor only) captured in FR-007 and User Story 3.
- Gas resistance explicitly excluded (FR-013) per Oscar's "no much point" assessment.
@@ -0,0 +1,119 @@
# UI Contracts: Air Quality Telemetry
This feature is internal to the mobile app (no public API, library, or external service interface). The contracts below define the UI component interfaces for implementation consistency.
## Info Card Contract
### AirQualityInfoCards
**Input**: `Node` with `hasAirQualityMetrics == true`
**Output**: List of `VectorMetricInfo` items for rendering via existing `InfoCard` composable
**Card set** (shown when value > 0):
| Card | Label String | Value Format | Unit | Icon |
|------|-------------|--------------|------|------|
| PM1.0 | `Res.string.pm1_0` | Integer | µg/m³ | `MeshtasticIcons.AirQuality` |
| PM2.5 | `Res.string.pm2_5` | Integer | µg/m³ | `MeshtasticIcons.AirQuality` |
| PM10 | `Res.string.pm10` | Integer | µg/m³ | `MeshtasticIcons.AirQuality` |
| CO₂ | `Res.string.co2` | Integer | ppm | `MeshtasticIcons.AirQuality` |
**CO₂ special behavior**: Value text color determined by `Co2Severity.fromPpm(value)`.
## Log Screen Contract
### AirQualityMetricsScreen
**Route**: `NodeDetailRoute.AirQualityMetrics(destNum: Int)`
**Composable signature**:
```kotlin
@Composable
fun AirQualityMetricsScreen(
nodeNum: Int,
modifier: Modifier = Modifier,
)
```
**Delegates to**: `BaseMetricScreen` with:
- `metricsState`: `AirQualityMetricsState` (implements existing metric state interface)
- `chartContent`: Thin-line Vico chart with `AirQuality` enum for series selection
- `historyContent`: LazyColumn of timestamped metric cards
- `exportAction`: `saveAirQualityMetricsCSV()` from `MetricsViewModel`
- `timeFrameSelector`: Reuses existing time frame filter UI
- `onRequestTelemetry`: `{ viewModel.requestTelemetry(TelemetryType.AIR_QUALITY) }` — renders a "Request" FAB/button allowing users to manually fetch fresh readings from the node
## Request→Response→Display Contract
### Full Loop
```
┌─────────────────────────────────────────────────────────────────┐
│ User taps "Request Air-Quality Metrics" │
│ (node detail TelemetricActionsSection OR log screen button) │
└─────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ MetricsViewModel.requestTelemetry(TelemetryType.AIR_QUALITY) │
│ → CommandSender.requestTelemetry(destNum, AIR_QUALITY) │
└─────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ CommandSenderImpl encodes AdminMessage with │
│ Telemetry(air_quality_metrics = AirQualityMetrics()) │
│ → sends via MeshProtos.ToRadio │
└─────────────────────────┬───────────────────────────────────────┘
▼ (mesh radio)
┌─────────────────────────────────────────────────────────────────┐
│ Remote node responds: Telemetry packet with populated │
│ air_quality_metrics field │
└─────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ TelemetryPacketHandlerImpl.handle() │
│ → air_quality_metrics != null branch (NEW) │
│ → nextNode = nextNode.copy(airQualityMetrics = airQuality) │
└─────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ NodeManager persists → NodeEntity.air_quality_metrics (BLOB) │
│ Node state Flow emits update │
└─────────────────────────┬───────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ UI recomposes: │
│ • Info cards show updated PM/CO₂ values │
│ • Log screen appends new history entry │
│ • Chart adds new data point │
└─────────────────────────────────────────────────────────────────┘
```
**Existing (no changes needed)**:
- `TelemetricActionsSection.kt` line 181 — button UI
- `CommandSenderImpl.kt` line 303 — request encoding
- `MetricsViewModel.requestTelemetry()` — method already handles any `TelemetryType`
**New code required**:
- `TelemetryPacketHandlerImpl.kt` — add `air_quality_metrics` branch to `when` block
- Air Quality log screen — wire `onRequestTelemetry` callback to `BaseMetricScreen`
## CSV Export Contract
### Column Format
```csv
"date","time","pm10_standard","pm25_standard","pm100_standard","pm10_environmental","pm25_environmental","pm100_environmental","particles_03um","particles_05um","particles_10um","particles_25um","particles_50um","particles_100um","co2","co2_temperature","co2_humidity","form_formaldehyde","form_humidity","form_temperature","pm40_standard","particles_40um","pm_temperature","pm_humidity","pm_voc_idx","pm_nox_idx","particles_tps"
```
- Date format: locale-aware via `epochSeconds``exportCsv` helper
- Missing/zero fields: empty string in CSV cell
- Float fields: raw numeric (no formatting applied to CSV output)
## Navigation Contract
### Entry Points
1. **LogsType list**`LogsType.AIR_QUALITY` entry visible when `node.hasAirQualityMetrics`
2. **Route**`NodeDetailRoute.AirQualityMetrics(destNum)` registered in `NodesNavigation.kt`
3. **Back navigation**`NavigationBackHandler` returns to node detail screen
@@ -0,0 +1,142 @@
# Data Model: Air Quality Telemetry Display
## Entities
### AirQualityMetrics (Proto — read-only upstream)
Source: `core/proto/src/main/proto/meshtastic/telemetry.proto` (field 4 of `Telemetry` oneof)
| Field | Type | Unit | Display | Description |
|-------|------|------|---------|-------------|
| `pm10_standard` | uint32 | µg/m³ | Primary | PM1.0 standard concentration |
| `pm25_standard` | uint32 | µg/m³ | Primary | PM2.5 standard concentration |
| `pm100_standard` | uint32 | µg/m³ | Primary | PM10.0 standard concentration |
| `pm10_environmental` | uint32 | µg/m³ | CSV-only | PM1.0 environmental concentration |
| `pm25_environmental` | uint32 | µg/m³ | CSV-only | PM2.5 environmental concentration |
| `pm100_environmental` | uint32 | µg/m³ | CSV-only | PM10.0 environmental concentration |
| `particles_03um` | uint32 | #/0.1L | CSV-only | 0.3µm particle count |
| `particles_05um` | uint32 | #/0.1L | CSV-only | 0.5µm particle count |
| `particles_10um` | uint32 | #/0.1L | CSV-only | 1.0µm particle count |
| `particles_25um` | uint32 | #/0.1L | CSV-only | 2.5µm particle count |
| `particles_50um` | uint32 | #/0.1L | CSV-only | 5.0µm particle count |
| `particles_100um` | uint32 | #/0.1L | CSV-only | 10.0µm particle count |
| `co2` | uint32 | ppm | Primary | CO₂ concentration (color-coded) |
| `co2_temperature` | float | °C | CSV-only | CO₂ sensor temperature |
| `co2_humidity` | float | %RH | CSV-only | CO₂ sensor relative humidity |
| `form_formaldehyde` | float | ppb | CSV-only | Formaldehyde concentration |
| `form_humidity` | float | %RH | CSV-only | Formaldehyde sensor humidity |
| `form_temperature` | float | °C | CSV-only | Formaldehyde sensor temperature |
| `pm40_standard` | uint32 | µg/m³ | CSV-only | PM4.0 standard concentration |
| `particles_40um` | uint32 | #/0.1L | CSV-only | 4.0µm particle count |
| `pm_temperature` | float | °C | CSV-only | PM sensor temperature |
| `pm_humidity` | float | %RH | CSV-only | PM sensor humidity |
| `pm_voc_idx` | float | ppb | CSV-only | VOC index |
| `pm_nox_idx` | float | ppb | CSV-only | NOx index |
| `particles_tps` | float | µm | CSV-only | Typical particle size |
### Node (Domain Model)
File: `core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt`
**New fields:**
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `airQualityMetrics` | `AirQualityMetrics` | `AirQualityMetrics()` | Latest air quality readings |
**New computed properties:**
| Property | Type | Logic |
|----------|------|-------|
| `hasAirQualityMetrics` | `Boolean` | `airQualityMetrics != AirQualityMetrics()` |
### NodeEntity (Database Entity)
File: `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt`
**New column:**
| Column | Affinity | Type | Default | Description |
|--------|----------|------|---------|-------------|
| `air_quality_metrics` | BLOB | `Telemetry` | `Telemetry()` | Serialized Telemetry proto containing air_quality_metrics oneof |
**New accessor property:**
```kotlin
val airQualityMetrics: AirQualityMetrics?
get() = airQualityTelemetry.air_quality_metrics
```
### Database Migration
| From | To | Type | Change |
|------|-----|------|--------|
| 38 | 39 | Auto-migration | Add nullable `air_quality_metrics` BLOB column to `node_entity` table |
## Relationships
```
Telemetry Proto (oneof)
└── AirQualityMetrics (field 4)
MeshPacket
→ TelemetryPacketHandlerImpl (decode)
→ Node.airQualityMetrics (in-memory state)
→ NodeEntity.air_quality_metrics (persisted BLOB)
Node
├── hasAirQualityMetrics → drives info card visibility
└── airQualityMetrics → feeds info card values + log screen history
```
## Enumerations
### Co2Severity
New utility in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt`
| Level | Range (ppm) | Color Token | Label |
|-------|-------------|-------------|-------|
| GOOD | 01000 | M3 tertiary/green | Good |
| STUFFY | 10002000 | M3 secondary/yellow | Stuffy |
| POOR | 20005000 | Custom warning/orange | Poor |
| UNSAFE | 500030000 | M3 error/red | Unsafe |
| EVACUATE | 30000+ | M3 error/red + emphasis | Evacuate |
### LogsType.AIR_QUALITY
New enum entry in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt`
```kotlin
AIR_QUALITY(Res.string.air_quality_metrics_log, MeshtasticIcons.AirQuality, { NodeDetailRoute.AirQualityMetrics(it) })
```
### AirQuality (Chart Metric Enum)
New enum for selectable chart metrics in the Air Quality log screen:
| Entry | Label | Unit | Proto Field |
|-------|-------|------|-------------|
| PM1_0 | PM1.0 | µg/m³ | `pm10_standard` |
| PM2_5 | PM2.5 | µg/m³ | `pm25_standard` |
| PM10 | PM10 | µg/m³ | `pm100_standard` |
| CO2 | CO₂ | ppm | `co2` |
## Validation Rules
- Zero/null proto field values → field is "not reported" → hide from info cards
- CO₂ color severity only applied when `co2 > 0`
- Float fields (temperatures, VOC, NOx) pre-formatted with `NumberFormatter.format()` before display
- No upper-bound validation on sensor values (raw display per spec non-goals)
## State Transitions
No complex state machine. The data flow is unidirectional:
```
Packet received → Node updated → UI recomposes
```
The only "state" is presence/absence of data:
- No telemetry → no info cards shown, empty state on log screen
- Telemetry received → info cards visible, log entries populated
@@ -0,0 +1,103 @@
# Implementation Plan: Air Quality Telemetry Display
**Branch**: `20260601-074653-air-quality-telemetry` | **Date**: 2025-06-01 | **Spec**: `specs/20260601-074653-air-quality-telemetry/spec.md`
**Input**: Feature specification from `specs/20260601-074653-air-quality-telemetry/spec.md`
## Summary
Display air quality telemetry (PM1.0, PM2.5, PM10, CO₂) from the `AirQualityMetrics` proto message on node detail info cards with CO₂ severity color-coding, and provide a dedicated metrics log screen with history, thin-line charting, and CSV export. The full request→response→display loop must work end-to-end: request infrastructure already exists (button in `TelemetricActionsSection`, encoding in `CommandSenderImpl`), but the **response** path is missing — `TelemetryPacketHandlerImpl` must handle the `air_quality_metrics` oneof to store data on the Node model, triggering UI updates. The log screen includes its own "Request" action button via `BaseMetricScreen`'s `onRequestTelemetry` callback. Implementation follows the established Environment/Power metrics patterns: BLOB-persisted `Telemetry` proto in `NodeEntity`, oneof handling in `TelemetryPacketHandlerImpl`, `BaseMetricScreen` composable for the log, and metric-specific CSV export in `MetricsViewModel`.
## Technical Context
**Language/Version**: Kotlin 2.3+ targeting JDK 21
**Primary Dependencies**: Compose Multiplatform (UI), Room KMP (database), Koin 4.2+ (DI), Wire/protobuf (proto), Vico (charts), Okio (filesystem/CSV)
**Storage**: Room KMP — new BLOB column `air_quality_metrics` on `NodeEntity` storing serialized `Telemetry` proto (same pattern as `environment_metrics` and `power_metrics` columns)
**Testing**: `./gradlew :core:model:test :core:data:test :feature:node:test` for unit tests; Compose screenshot tests for UI verification
**Target Platform**: Android (minSdk 24) + Compose Desktop (JVM)
**Project Type**: Mobile app (KMP multi-target)
**Performance Goals**: Info cards render within same frame budget as Environment cards; chart smooth with 1,000+ data points (NFR-001, NFR-002)
**Constraints**: All business logic and UI in `commonMain`; no `java.*`/`android.*` imports in common code; read-only proto submodule
**Scale/Scope**: 1 new database column, 1 new navigation route, ~3 new composable files, ~1 new ViewModel extension, database migration 38→39
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
- **I. Kotlin Multiplatform Core**: ✅ All new code resides in `commonMain` source sets across `core:model`, `core:data`, `core:database`, `core:navigation`, `core:ui`, `core:resources`, and `feature:node`. No platform-specific (`androidMain`/`desktopMain`) code required — existing BLOB serialization, Room KMP, and Compose Multiplatform patterns handle all platform concerns.
- **II. Zero Lint Tolerance**: ✅ Will run:
```
./gradlew spotlessApply spotlessCheck detekt
```
Scoped module tests: `:core:model:test :core:data:test :core:database:test :feature:node:test`
- **III. Compose Multiplatform UI**: ✅ All UI uses Compose Multiplatform composables (`BaseMetricScreen`, `InfoCard`, `SelectableMetricCard`). Float values pre-formatted with `NumberFormatter.format()`. Navigation via `MeshtasticNavDisplay` using serializable `NodeDetailRoute.AirQualityMetrics` route. No Jetpack-only APIs.
- **IV. Privacy First**: ✅ Only raw sensor numerics displayed/stored. No PII, location, or crypto keys involved. Proto submodule (`core/proto`) not modified — `AirQualityMetrics` message already exists in upstream proto.
- **V. Design Standards Compliance**: ✅ Cross-platform design specs referenced: `meshtastic/design/issues/51` and `meshtastic/design/issues/53`. Chart style (thin lines, dot only at selection) per Oscar's guidance. UI reuses existing metric card patterns already validated against design standards.
- **VI. Verify Before Push**: ✅ Local verification:
```
./gradlew spotlessApply spotlessCheck detekt :core:model:test :core:data:test :core:database:test :feature:node:test
```
Post-push: `gh pr checks <PR>` or `gh run list --branch 20260601-074653-air-quality-telemetry --limit 5`
## Project Structure
### Documentation (this feature)
```text
specs/20260601-074653-air-quality-telemetry/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output (internal UI contracts)
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
core/
├── model/src/commonMain/kotlin/org/meshtastic/core/model/
│ └── Node.kt # Add airQualityMetrics field + hasAirQualityMetrics
├── data/src/commonMain/kotlin/org/meshtastic/core/data/manager/
│ └── TelemetryPacketHandlerImpl.kt # Handle air_quality_metrics oneof (response path)
├── database/src/commonMain/kotlin/org/meshtastic/core/database/
│ ├── entity/NodeEntity.kt # Add air_quality_metrics BLOB column + accessor
│ └── MeshtasticDatabase.kt # Bump version 38→39 (auto-migration)
├── navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/
│ └── Routes.kt # Add NodeDetailRoute.AirQualityMetrics
├── ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/
│ └── Co2Severity.kt # CO₂ threshold color utility (new)
└── resources/src/commonMain/composeResources/values/
└── strings.xml # Add air quality string resources
feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/
├── component/
│ └── AirQualityMetrics.kt # Info card composable (new)
├── metrics/
│ └── AirQualityMetrics.kt # Log screen + chart + request button (new)
├── model/
│ └── LogsType.kt # Add AIR_QUALITY enum entry
├── detail/
│ └── NodesNavigation.kt # Register AirQualityMetrics route
└── MetricsViewModel.kt # Add air quality CSV export + chart state + requestTelemetry(AIR_QUALITY)
```
**Structure Decision**: KMP multi-module mobile app structure. New code distributed across existing `core:*` and `feature:node` modules following the established Environment/Power metrics pattern. No new modules required.
## Complexity Tracking
> No constitution violations. All gates pass without exception.
@@ -0,0 +1,91 @@
# Quickstart: Air Quality Telemetry Display
## Prerequisites
- Kotlin 2.3+ / JDK 21
- Android Studio with KMP plugin or IntelliJ with Compose Multiplatform
- Project builds successfully: `./gradlew assembleDebug`
- Proto submodule initialized: `git submodule update --init`
## Build & Verify
```bash
# Full build
./gradlew assembleDebug
# Lint + format
./gradlew spotlessApply spotlessCheck detekt
# Unit tests for touched modules
./gradlew :core:model:test :core:data:test :core:database:test :feature:node:test
```
## Key Files to Modify
### 1. Node Model (`core:model`)
```
core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
```
Add `airQualityMetrics` field and `hasAirQualityMetrics` computed property.
### 2. Telemetry Handler (`core:data`)
```
core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt
```
Add `air_quality_metrics` branch to the telemetry oneof `when` block.
### 3. Database Entity (`core:database`)
```
core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt
core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt
```
Add BLOB column + bump version to 39.
### 4. Navigation Route (`core:navigation`)
```
core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt
```
Add `NodeDetailRoute.AirQualityMetrics(destNum: Int)`.
### 5. Info Cards (`feature:node`)
```
feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt
```
New composable building `VectorMetricInfo` list from `AirQualityMetrics` proto.
### 6. Log Screen (`feature:node`)
```
feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt
```
New composable using `BaseMetricScreen` with chart + history + export.
### 7. LogsType Enum
```
feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt
```
Add `AIR_QUALITY` entry.
### 8. String Resources
```
core/resources/src/commonMain/composeResources/values/strings.xml
```
Add air quality labels, then run `python3 scripts/sort-strings.py`.
## Testing Approach
1. **Unit test** the CO₂ severity threshold mapping
2. **Unit test** the info card list builder (given metrics, expect correct card output)
3. **Unit test** CSV export column generation
4. **Integration test** telemetry packet handler correctly updates Node state
5. **Screenshot test** (if applicable) info cards and log screen composables
## Patterns to Follow
| Pattern | Reference File |
|---------|---------------|
| Info cards | `feature/node/src/commonMain/.../component/EnvironmentMetrics.kt` |
| Log screen | `feature/node/src/commonMain/.../metrics/EnvironmentMetrics.kt` |
| CSV export | `MetricsViewModel.kt``saveEnvironmentMetricsCSV()` |
| Route registration | `NodesNavigation.kt``NodeDetailRoute.EnvironmentMetrics::class` |
| Database column | `NodeEntity.kt``environment_metrics` BLOB column |
| Icon usage | `core/ui/.../icon/Telemetry.kt``MeshtasticIcons.AirQuality` |
@@ -0,0 +1,133 @@
# Research: Air Quality Telemetry Display
## R1: Telemetry Packet Handling Pattern
**Decision**: Add `air_quality_metrics` oneof handling to `TelemetryPacketHandlerImpl` following the exact pattern used for `environment_metrics` and `power_metrics`.
**Rationale**: The handler at `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt` already pattern-matches on the Telemetry oneof variants. The `air_quality_metrics` variant (field 4 in the Telemetry proto) is not yet handled — it simply falls through. Adding a branch that copies the metrics to the Node model is trivial and consistent.
**Alternatives considered**:
- Separate handler class → rejected: adds indirection for a single oneof branch; other metrics don't do this.
## R2: Database Storage Strategy
**Decision**: Add a new BLOB column `air_quality_metrics` (type `Telemetry`) to `NodeEntity`, auto-migrating from version 38 to 39.
**Rationale**: Environment (`environment_metrics`) and Power (`power_metrics`) use the same pattern — store the full `Telemetry` proto as a binary BLOB. Room KMP auto-migration handles new nullable columns cleanly (existing rows get null/default). The `NodeEntity` accessor property unwraps the oneof for type-safe access.
**Alternatives considered**:
- Individual columns per metric field → rejected: 25 fields in `AirQualityMetrics` makes this unwieldy; BLOB serialization is proven.
- Shared column with environment → rejected: different telemetry type, different update cadence, violates existing separation pattern.
## R3: Node Model Extension
**Decision**: Add `airQualityMetrics: AirQualityMetrics = AirQualityMetrics()` field and `hasAirQualityMetrics` boolean accessor to the `Node` data class.
**Rationale**: Mirrors `environmentMetrics`/`hasEnvironmentMetrics` pattern exactly. The `has*` accessor compares against the default empty instance to determine if data is present.
**Alternatives considered**: None — this is the established pattern.
## R4: CO₂ Severity Color Thresholds
**Decision**: Create a `Co2Severity` enum/utility in `core:ui` that maps CO₂ ppm to M3-compatible color tokens:
- Good: 4001000 ppm → `Color.Green` / M3 tertiary
- Stuffy: 10002000 ppm → `Color.Yellow` / M3 secondary
- Poor: 20005000 ppm → `Color.Orange` / custom warning token
- Unsafe: 5000+ ppm → `Color.Red` / M3 error
- Evacuate: 30000+ ppm → `Color.Red` + bold / M3 error with emphasis
**Rationale**: Per design/issues/53 (Oscar's recommendation). Using M3-compatible color tokens ensures theme consistency across light/dark modes. Existing `IndoorAirQuality.kt` in `core/ui/component/` provides a precedent for threshold-based coloring (IAQ severity levels).
**Alternatives considered**:
- Hardcoded hex colors → rejected: breaks M3 theming and dark mode.
- Reuse IAQ severity → rejected: different scale (IAQ 0-500 vs CO₂ 400-40000 ppm), different semantics.
## R5: Chart Rendering Style
**Decision**: Use thin-line charts via Vico library with dot marker visible only at the selected/cursor position. No persistent markers on data points.
**Rationale**: Per design/issues/53 recommendation. The existing chart infrastructure in `BaseMetricScreen` already uses Vico for line charts. The thin-line-only style may differ from current Environment charts (which may show dots) — this feature follows the updated design guidance.
**Alternatives considered**:
- Thick lines with dots at every point → rejected: explicitly against design guidance ("avoid clutter").
- Bar charts for PM data → rejected: line charts show temporal trends better for continuous monitoring.
## R6: Navigation Integration
**Decision**: Add `NodeDetailRoute.AirQualityMetrics(destNum: Int)` as a new serializable data class in the `NodeDetailRoute` sealed interface. Register in `NodesNavigation.kt` via `addNodeDetailScreenComposable`.
**Rationale**: Exact pattern used by all other metric routes (Device, Environment, Power, Signal, etc.). The `LogsType.AIR_QUALITY` enum entry drives the navigation.
**Alternatives considered**: None — established pattern is clear.
## R7: CSV Export Column Set
**Decision**: Export ALL proto fields as CSV columns, including secondary fields (particle counts, VOC, NOx, formaldehyde, co-read temp/humidity). Headers:
```
"date","time","pm10_standard","pm25_standard","pm100_standard","pm10_environmental","pm25_environmental","pm100_environmental","particles_03um","particles_05um","particles_10um","particles_25um","particles_50um","particles_100um","co2","co2_temperature","co2_humidity","form_formaldehyde","form_humidity","form_temperature","pm40_standard","particles_40um","pm_temperature","pm_humidity","pm_voc_idx","pm_nox_idx","particles_tps"
```
**Rationale**: FR-008 requires all available proto fields. External analysis tools benefit from complete data. Empty/zero fields exported as empty cells per spec edge cases.
**Alternatives considered**:
- Only export displayed (primary) fields → rejected: spec explicitly requires all proto fields for external analysis use case.
## R8: Info Card Display Logic
**Decision**: Display info cards for PM1.0 (standard), PM2.5 (standard), PM10 (standard), and CO₂ when non-zero. Use `VectorMetricInfo` pattern from `EnvironmentMetrics.kt` component. Hide cards for zero/null values per existing convention.
**Rationale**: FR-003 specifies standard concentrations as primary display metrics. The existing `EnvironmentMetrics.kt` component pattern (build info cards list, filter nulls/NaN/zero) is well-established.
**Alternatives considered**:
- Show all 25 fields on info cards → rejected: overwhelming; design guidance says PM+CO₂ are primary.
- Show environmental concentrations instead of standard → rejected: spec explicitly calls for standard concentrations.
## R9: Icon Selection
**Decision**: Use existing `MeshtasticIcons.AirQuality` (maps to `ic_air` drawable) for the info card and log type entry.
**Rationale**: Icon already exists in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt` — no new vector asset needed.
**Alternatives considered**: None — purpose-built icon already available.
## R10: Telemetry Request Button
**Decision**: No new work needed for the request button on the **node detail screen**`TelemetricActionsSection` already includes it (line 179/181) and `CommandSenderImpl` already encodes the request (line 303). However, the **response path** is entirely missing — `TelemetryPacketHandlerImpl` does not yet handle the `air_quality_metrics` oneof, so responses are silently dropped.
**Rationale**: Verified in codebase:
- Request UI: `TelemetricActionsSection.kt` line 181 → `NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY)`
- Request encoding: `CommandSenderImpl.kt` line 303 → constructs `Telemetry(air_quality_metrics = AirQualityMetrics())`
- Response handling: `TelemetryPacketHandlerImpl.kt` — the `when` block only handles `device_metrics`, `environment_metrics`, and `power_metrics`; `air_quality_metrics` falls through unhandled
The critical gap is in the handler. Without R1 (adding the oneof branch), the request button does nothing visible.
**Alternatives considered**: N/A — infrastructure exists, just needs the response path wired up.
## R11: Log Screen Request Action Button
**Decision**: The Air Quality log screen must include a "Request" action button via `BaseMetricScreen`'s `onRequestTelemetry` callback, calling `viewModel.requestTelemetry(TelemetryType.AIR_QUALITY)`.
**Rationale**: Environment metrics log screen already does this (line 96 of `EnvironmentMetrics.kt`):
```kotlin
onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) },
```
The `MetricsViewModel.requestTelemetry()` method already exists and supports `TelemetryType.AIR_QUALITY` — it delegates to `CommandSender`. The only work is wiring the callback in the new Air Quality log screen composable.
**Alternatives considered**:
- Omit request button from log screen → rejected: inconsistent with Environment/Power patterns, and users may want to refresh data while reviewing history.
## R12: End-to-End Request→Response→Display Flow
**Decision**: Document and verify the complete loop:
1. **User taps "Request Air-Quality Metrics"** (node detail OR log screen)
2. **`MetricsViewModel.requestTelemetry(TelemetryType.AIR_QUALITY)`** → delegates to `CommandSender`
3. **`CommandSenderImpl`** constructs `AdminMessage` with `Telemetry(air_quality_metrics = AirQualityMetrics())` and sends via mesh
4. **Node responds** with a `Telemetry` packet containing populated `air_quality_metrics`
5. **`TelemetryPacketHandlerImpl`** decodes the response, matches `air_quality_metrics != null`, calls `nextNode = nextNode.copy(airQualityMetrics = airQuality)` **(NEW CODE)**
6. **`NodeManager`** persists updated Node → `NodeEntity.air_quality_metrics` BLOB column **(NEW COLUMN)**
7. **UI recomposes** — info cards and log screen observe Node state via Flow
**Rationale**: This is the exact same flow used by Environment metrics. The only missing pieces are step 5 (handler branch) and step 6 (database column) — both addressed by R1 and R2.
**Alternatives considered**: None — this is the established unidirectional data flow.
@@ -0,0 +1,233 @@
# Feature Specification: Air Quality Telemetry Display
**Feature Branch**: `20260601-074653-air-quality-telemetry`
**Created**: 2025-06-01
**Status**: Draft
**Input**: User description: "Display raw air quality / particulate sensor data from the AirQualityMetrics proto message on node detail info cards and in a dedicated metrics log screen with history, graphing, and CSV export — matching the existing patterns for Environment and Power metrics."
**Cross-Platform Spec**: https://github.com/meshtastic/design/issues/51, https://github.com/meshtastic/design/issues/53
## Summary
Add support for displaying air quality and particulate sensor telemetry data received from nodes equipped with air quality sensors (SEN5X, PMSA003I, SCD30, SCD4X). The feature focuses on the primary displayable metrics — PM1.0, PM2.5, PM10 (standard concentrations) and CO₂ — with CO₂ presented using color-coded severity thresholds per upstream design guidance. Secondary fields (particle counts, VOC index, NOx index, formaldehyde) are stored and exportable but given less visual prominence. The feature provides node detail info cards for at-a-glance status and a dedicated metrics log screen with timestamped history, thin-line charting, and CSV export, following the established patterns used by Environment and Power metrics.
### Upstream Design Decisions (design/issues/51 + design/issues/53)
Per Oscar (@oscgonfer) from the Meshtastic design team:
- **PM data** (PM1.0, PM2.5, PM10) — useful as raw µg/m³ values. Primary display metrics.
- **CO₂** — useful as raw ppm. Display with color-coded thresholds:
- Good: 4001000 ppm
- Stuffy: 10002000 ppm
- Poor: 20005000 ppm
- Unsafe (8h work): 5000+ ppm
- Evacuate: 3000040000+ ppm
- **Gas resistance (Ohms)** — low value as raw display; only useful after IAQ processing. Not included in air quality display (IAQ already shown in Environment metrics).
- **Chart style** — thin lines only; dot marker shown only at the selected/cursor position to avoid clutter.
- **Telemetry category** — Air Quality is distinct from Environment/Weather. PM and chemical pollutants are its domain.
## Goals
1. Display the most recent air quality readings on the node detail info card so users can quickly assess current conditions without navigating away
2. Provide a dedicated Air Quality metrics log screen with historical readings, selectable line charts, and time frame filtering for trend analysis
3. Enable CSV export of air quality data for external analysis and reporting
4. Persist air quality telemetry to the local database so readings survive app restarts and are available for historical review
5. Integrate seamlessly into existing telemetry navigation and UI patterns so users experience consistent behavior across all metric types
## Non-Goals
- Calculating or displaying derived air quality indices (AQI) — only raw sensor values are shown (with CO₂ threshold coloring as the one exception per design guidance)
- Displaying raw gas resistance — this is handled by the existing IAQ display in Environment metrics
- Configuring air quality sensor hardware settings from the app
- Setting alert thresholds or push notifications for unhealthy readings
- Aggregating air quality data from multiple nodes into a combined view
- Displaying air quality data on the map layer
- Modifying the proto definitions (upstream read-only)
- Processing or displaying data best sent via MQTT for external analysis (per design/issues/51)
## User Scenarios & Testing *(mandatory)*
### User Story 1 - View Current Air Quality Readings (Priority: P1)
A user with an air quality sensor-equipped node wants to see the latest PM2.5, PM10, and CO₂ values at a glance on the node detail screen without extra navigation.
**Why this priority**: This is the most common interaction — users check current conditions frequently and need immediate visibility of key readings.
**Independent Test**: Can be fully tested by receiving a single air quality telemetry packet and verifying the info cards render correct values on the node detail screen.
**Acceptance Scenarios**:
1. **Given** a node has received air quality telemetry, **When** the user views the node detail screen, **Then** info cards display the latest PM1.0, PM2.5, PM10 (standard concentrations) and CO₂ with appropriate labels and units (µg/m³ for PM, ppm for CO₂)
2. **Given** a node has received air quality telemetry with CO₂ data, **When** the CO₂ info card is displayed, **Then** the value is color-coded according to severity thresholds (Good ≤1000, Stuffy 10002000, Poor 20005000, Unsafe 5000+, Evacuate 30000+)
3. **Given** a node has never received air quality telemetry, **When** the user views the node detail screen, **Then** no air quality info cards are shown
4. **Given** a node receives updated air quality telemetry while the detail screen is open, **When** the new packet arrives, **Then** the info cards update to reflect the latest values
---
### User Story 2 - Browse Air Quality History (Priority: P2)
A user wants to review historical air quality readings to understand how conditions changed throughout the day — for example, checking if PM2.5 spiked after a nearby event.
**Why this priority**: Historical context transforms raw numbers into actionable insight; this is the primary reason users track metrics over time.
**Independent Test**: Can be fully tested by populating telemetry history and verifying the log screen shows timestamped cards in chronological order with correct values.
**Acceptance Scenarios**:
1. **Given** multiple air quality telemetry readings exist for a node, **When** the user navigates to the Air Quality metrics log, **Then** timestamped history cards are displayed in reverse-chronological order showing key metric values
2. **Given** the user selects a time frame filter, **When** the filter is applied, **Then** only readings within the selected time frame are displayed
3. **Given** no air quality telemetry exists for a node, **When** the user navigates to the Air Quality metrics log, **Then** an appropriate empty state is shown
---
### User Story 3 - Graph Air Quality Trends (Priority: P2)
A user wants to visually identify trends and correlations in air quality data by viewing line charts of selected metrics over time.
**Why this priority**: Graphing enables pattern recognition (e.g., daily PM cycles) that raw numbers alone cannot convey.
**Independent Test**: Can be fully tested by populating telemetry history and verifying chart renders with correct data points and legend entries.
**Acceptance Scenarios**:
1. **Given** air quality history exists, **When** the user views the chart on the Air Quality metrics log, **Then** thin line charts (no large dot markers) plot the selected metrics over time with a legend identifying each series
2. **Given** the user taps a point on the chart, **When** the selection is made, **Then** a single dot marker appears at the selected position and the corresponding history card is highlighted/scrolled to
3. **Given** some metric values are zero or absent for certain readings, **When** the chart renders, **Then** those data points are omitted gracefully without breaking the chart line
---
### User Story 4 - Export Air Quality Data (Priority: P3)
A user wants to export air quality readings to CSV for external analysis, regulatory reporting, or sharing with environmental agencies.
**Why this priority**: Export enables integration with external tools but is a secondary workflow compared to in-app viewing.
**Independent Test**: Can be fully tested by triggering CSV export and verifying the file contains correct headers and values matching the displayed history.
**Acceptance Scenarios**:
1. **Given** air quality history exists, **When** the user taps the export action, **Then** a CSV file is generated containing all displayed readings with appropriate column headers
2. **Given** a time frame filter is active, **When** the user exports, **Then** only the filtered readings are included in the CSV
3. **Given** some readings have partial data (e.g., only PM values, no CO₂), **When** CSV is exported, **Then** missing values are represented as empty cells
---
### User Story 5 - Navigate to Air Quality Metrics Log (Priority: P1)
A user sees air quality info cards on the node detail screen and wants to drill into the full history and charts.
**Why this priority**: Navigation is foundational — without it, the log screen is inaccessible.
**Independent Test**: Can be fully tested by verifying the Air Quality entry appears in the logs list and navigation leads to the correct screen.
**Acceptance Scenarios**:
1. **Given** a node has air quality telemetry, **When** the user views available metric logs for the node, **Then** an "Air Quality" option is listed with appropriate icon
2. **Given** the user selects the Air Quality log entry, **When** navigation occurs, **Then** the Air Quality metrics log screen opens for that node
---
### Edge Cases
- What happens when only a subset of air quality fields are populated (e.g., PM-only sensor with no CO₂)? Only populated fields are displayed; empty/zero fields are hidden.
- What happens when a sensor reports unrealistic values (e.g., PM2.5 = 0 from a fresh boot)? Zero values are displayed as-is since the app shows raw sensor data without validation.
- What happens when the device receives air quality telemetry from a very old firmware version that has fewer fields? Newer fields default to zero/absent per proto semantics and are simply not displayed.
- What happens when thousands of air quality readings accumulate? The same pagination/scrolling approach used by other metric logs applies (LazyColumn with efficient composable reuse).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: System MUST store the latest AirQualityMetrics on the Node model when received via telemetry
- **FR-002**: System MUST persist air quality telemetry to the database so data survives app restarts
- **FR-003**: System MUST display info cards for PM1.0, PM2.5, PM10 (standard concentrations) and CO₂ on the node detail screen when non-zero values are present
- **FR-004**: System MUST color-code the CO₂ display value using severity thresholds: Good (01000 ppm), Stuffy (10002000 ppm), Poor (20005000 ppm), Unsafe (500030000 ppm), Evacuate (30000+ ppm). Values below outdoor ambient (~420 ppm) are still categorized as Good.
- **FR-005**: System MUST provide a dedicated Air Quality metrics log screen accessible from the node detail logs list
- **FR-006**: System MUST display timestamped history cards on the Air Quality log screen showing PM and CO₂ values
- **FR-007**: System MUST render thin-line charts (dot marker only at selection point) for air quality metrics over time
- **FR-008**: System MUST support CSV export of displayed air quality readings with all available proto fields as columns (including secondary fields: particle counts, VOC index, NOx index, formaldehyde, co-read temperature/humidity)
- **FR-009**: System MUST support time frame filtering on the Air Quality log screen
- **FR-010**: System MUST handle partial data gracefully — only display fields that have meaningful non-zero values
- **FR-011**: System MUST handle the telemetry packet for `Telemetry.air_quality_metrics` oneof variant in the packet handler
- **FR-012**: System MUST include a database migration adding the air quality metrics column
- **FR-013**: System MUST NOT display raw gas_resistance in the Air Quality screen (IAQ is already shown in Environment metrics)
### Non-Functional Requirements
- **NFR-001**: Air quality info cards render within the same frame budget as existing Environment info cards (no perceptible additional lag)
- **NFR-002**: Chart rendering with 1,000+ data points remains smooth and scrollable
- **NFR-003**: All new UI composables and business logic reside in the `commonMain` source set for cross-platform compatibility
## Architecture
### Key Components
| Component | Module / File | Purpose |
|-----------|---------------|---------|
| Node model | `core/model/` | Store `airQualityMetrics` field and `hasAirQualityMetrics` accessor |
| TelemetryPacketHandlerImpl | `core/data/` | Handle `air_quality_metrics` oneof variant |
| NodeEntity | `core/database/` | Persist air quality telemetry as BLOB column |
| Database Migration | `core/database/` | Add `air_quality_metrics` column to NodeEntity |
| AirQualityMetrics info cards | `feature/node/component/` | Display current readings on node detail |
| AirQualityMetrics log screen | `feature/node/metrics/` | History, chart, CSV export |
| LogsType.AIR_QUALITY | `feature/node/model/` | Enum entry for navigation |
| NodeDetailRoute.AirQualityMetrics | `core/navigation/` | Route definition |
| MetricsViewModel extensions | `feature/node/` | Air quality graphing data and CSV export logic |
### Data Flow
```
MeshPacket (air_quality_metrics)
→ TelemetryPacketHandlerImpl (decode + update Node)
→ NodeManager (persist to NodeEntity via database)
→ UI observes Node state
→ Info Cards (node detail screen)
→ Metrics Log Screen (history + chart + export)
```
## Source-Set Impact
| Source Set | Impact | Justification |
|-----------|--------|---------------|
| `commonMain` | New composables, model updates, packet handler logic, navigation route, database migration | All business logic and UI per Constitution I, III |
| `androidMain` | None | No platform-specific code needed |
| `jvmMain` | None | No platform-specific code needed |
## Design Standards Compliance
- [ ] New screens reviewed against [design standards](https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md)
- [ ] M3 component selection verified — uses existing `InfoCard`, `SelectableMetricCard`, `BaseMetricScreen` composables
- [ ] Accessibility: TalkBack semantics on info cards, adequate touch targets, units included in content descriptions
- [ ] Typography: Consistent with existing metric cards (`labelSmall` for labels, `labelLarge` for values)
## Privacy Assessment
- [ ] No PII, location data, or cryptographic keys logged or exposed — only raw sensor numerics
- [ ] No new network calls that transmit user data — reads from existing mesh telemetry only
- [ ] Proto submodule (`core/proto`) not modified (read-only upstream)
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: Users can view current air quality readings on the node detail screen within 1 second of receiving telemetry
- **SC-002**: Users can access and browse full air quality history for any node with fewer than 3 taps from the node detail screen
- **SC-003**: Exported CSV files contain all air quality fields with correct headers and are importable by standard spreadsheet applications
- **SC-004**: Air quality metrics persist across app restarts with zero data loss
- **SC-005**: The Air Quality log screen supports the same time frame filters and chart interactions available on the Environment metrics log screen
## Assumptions
- All business logic and UI composables reside in `commonMain` source set
- String resources added to `core/resources/src/commonMain/composeResources/values/strings.xml`
- Icons use `MeshtasticIcons` (from `core/ui/icon/`) — a new air quality icon vector may be needed
- Float values pre-formatted with `NumberFormatter.format()` (CMP constraint)
- The `AirQualityMetrics` proto message is already available in the proto submodule and need not be modified
- The existing `BaseMetricScreen` composable framework is reused for the log screen (chart + list + export pattern)
- The telemetry request button for AIR_QUALITY already exists in `TelemetricActionsSection` and `CommandSenderImpl`
- Database migration follows the sequential numbering pattern established by prior migrations
- Zero-value fields from proto deserialization are treated as "not reported" and hidden from display (consistent with Environment metrics behavior)
- Primary display metrics: PM1.0, PM2.5, PM10 (standard concentrations in µg/m³) and CO₂ (ppm)
- Secondary metrics stored and exported but not prominently displayed: particle counts (particles/0.1L), VOC index (ppb), NOx index (ppb), formaldehyde, co-read temperature/humidity
- CO₂ threshold colors follow Oscar's guidance from design/issues/53 and use M3-compatible color tokens
- Chart rendering uses thin lines per design/issues/53 recommendation to avoid clutter; existing Environment metrics charts may use a different dot style — this feature follows the updated guidance
- Gas resistance is intentionally excluded from this feature; it is already surfaced as IAQ in the Environment metrics display
@@ -0,0 +1,226 @@
# Tasks: Air Quality Telemetry Display
**Input**: Design documents from `/specs/20260601-074653-air-quality-telemetry/`
**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅
**Tests**: Not explicitly requested in spec. Test tasks omitted per convention.
**Verification**: Constitution-required validation (spotlessCheck, detekt, module tests) included in final phase.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: String resources and shared utilities needed by all subsequent phases
- [ ] T001 Add air quality string resources (pm1_0, pm2_5, pm10, co2, air_quality_metrics_log, units) in `core/resources/src/commonMain/composeResources/values/strings.xml` then run `python3 scripts/sort-strings.py`
- [ ] T002 [P] Create `Co2Severity` enum and `fromPpm()` color utility in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt` mapping thresholds: Good 01000, Stuffy 10002000, Poor 20005000, Unsafe 500030000, Evacuate 30000+ to M3-compatible color tokens
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Data layer changes that MUST be complete before ANY user story UI can function
**⚠️ CRITICAL**: No user story work can begin until this phase is complete — without these changes, air quality telemetry packets are silently dropped and no data persists.
- [ ] T003 Add `airQualityMetrics: AirQualityMetrics = AirQualityMetrics()` field and `hasAirQualityMetrics` computed property to `Node` data class in `core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt`
- [ ] T004 Add `air_quality_metrics` BLOB column (type `Telemetry`, default `Telemetry()`) to `NodeEntity` in `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt` with `airQualityMetrics` accessor property
- [ ] T005 Bump database version 38→39 with auto-migration adding nullable `air_quality_metrics` column in `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt`
- [ ] T006 Handle `air_quality_metrics` oneof variant in `TelemetryPacketHandlerImpl` — add branch to `when` block that copies metrics to Node model via `nextNode.copy(airQualityMetrics = airQuality)` in `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt`
**Checkpoint**: Foundation ready — telemetry packets are now decoded, stored in-memory, and persisted to database. UI phases can begin.
---
## Phase 3: User Story 1 — View Current Air Quality Readings (Priority: P1) 🎯 MVP
**Goal**: Display latest PM1.0, PM2.5, PM10, and CO₂ values on the node detail info cards with CO₂ color-coded by severity thresholds.
**Independent Test**: Receive a single air quality telemetry packet and verify info cards render correct values with appropriate units and CO₂ coloring on the node detail screen.
### Implementation for User Story 1
- [ ] T007 [US1] Create `AirQualityInfoCards` composable in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt` — build `VectorMetricInfo` list for PM1.0, PM2.5, PM10 (µg/m³) and CO₂ (ppm) from `Node.airQualityMetrics`, filtering zero values, using `MeshtasticIcons.AirQuality` icon
- [ ] T008 [US1] Apply `Co2Severity.fromPpm()` color to the CO₂ info card value text in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt`
- [ ] T009 [US1] Integrate `AirQualityInfoCards` into the node detail screen — render cards when `node.hasAirQualityMetrics` is true, positioned after existing Environment/Power info cards
**Checkpoint**: User Story 1 complete — users see at-a-glance air quality readings on the node detail screen with CO₂ severity coloring. Cards hide when no data is present and update live when new packets arrive.
---
## Phase 4: User Story 5 — Navigate to Air Quality Metrics Log (Priority: P1)
**Goal**: Provide discoverable navigation from the node detail screen to the Air Quality metrics log.
**Independent Test**: Verify "Air Quality" entry appears in the logs list with correct icon, and tapping it navigates to the Air Quality log screen.
### Implementation for User Story 5
- [ ] T010 [P] [US5] Add `NodeDetailRoute.AirQualityMetrics(destNum: Int)` serializable data class to the `NodeDetailRoute` sealed interface in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt`
- [ ] T011 [P] [US5] Add `AIR_QUALITY` enum entry to `LogsType` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt` — use `Res.string.air_quality_metrics_log`, `MeshtasticIcons.AirQuality` icon, and `NodeDetailRoute.AirQualityMetrics(it)` factory
- [ ] T012 [US5] Register `NodeDetailRoute.AirQualityMetrics` route in `NodesNavigation.kt` via `addNodeDetailScreenComposable` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodesNavigation.kt`
**Checkpoint**: User Story 5 complete — Air Quality appears in the logs list and navigates to the metrics log screen (screen content implemented in next phases).
---
## Phase 5: User Story 2 — Browse Air Quality History (Priority: P2)
**Goal**: Display timestamped history cards on the Air Quality log screen with reverse-chronological readings and time frame filtering.
**Independent Test**: Populate telemetry history and verify the log screen shows timestamped cards in correct order with proper values and time frame filter works.
### Implementation for User Story 2
- [ ] T013 [US2] Create `AirQualityMetricsScreen` composable in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` — delegate to `BaseMetricScreen` with history content, time frame selector, and `onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.AIR_QUALITY) }` callback
- [ ] T014 [US2] Implement air quality metrics state class (`AirQualityMetricsState`) providing timestamped history cards from `NodeEntity.air_quality_metrics` BLOB list, showing PM1.0, PM2.5, PM10, CO₂ values with CO₂ severity color in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt`
- [ ] T015 [US2] Add air quality telemetry list query/accessor to `MetricsViewModel` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/MetricsViewModel.kt` — load historical telemetry entries for the node, support time frame filtering
**Checkpoint**: User Story 2 complete — users can browse timestamped air quality history with time frame filtering on the dedicated log screen.
---
## Phase 6: User Story 3 — Graph Air Quality Trends (Priority: P2)
**Goal**: Render thin-line Vico charts for selectable air quality metrics (PM1.0, PM2.5, PM10, CO₂) with dot marker only at the selected position.
**Independent Test**: Populate telemetry history and verify chart renders with correct data points, thin lines, legend entries, and tap-to-select behavior.
### Implementation for User Story 3
- [ ] T016 [P] [US3] Create `AirQuality` chart metric enum (PM1_0, PM2_5, PM10, CO2) with label, unit, and proto field mapping in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt`
- [ ] T017 [US3] Implement chart content section in `AirQualityMetricsScreen` using Vico thin-line chart with selectable metric series, dot marker only at cursor position, and graceful handling of zero/absent data points in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt`
- [ ] T018 [US3] Wire chart selection to history list — when user taps a chart point, highlight/scroll to corresponding history card in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt`
**Checkpoint**: User Story 3 complete — users can visualize air quality trends with interactive thin-line charts and chart-to-list synchronization.
---
## Phase 7: User Story 4 — Export Air Quality Data (Priority: P3)
**Goal**: Enable CSV export of all air quality proto fields (27 columns) with time frame filtering applied to exported data.
**Independent Test**: Trigger CSV export and verify file contains correct headers (date, time, all proto fields) and values matching displayed history, with missing values as empty cells.
### Implementation for User Story 4
- [ ] T019 [US4] Implement `saveAirQualityMetricsCSV()` in `MetricsViewModel` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/MetricsViewModel.kt` — generate CSV with all 27 proto field columns per contracts/ui-contracts.md, respecting active time frame filter, empty cells for zero/missing values
- [ ] T020 [US4] Wire export action into `AirQualityMetricsScreen`'s `BaseMetricScreen` `exportAction` parameter in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt`
**Checkpoint**: User Story 4 complete — users can export filtered air quality data to CSV for external analysis.
---
## Phase 8: Polish & Cross-Cutting Concerns
**Purpose**: Verification, consistency checks, and constitution compliance
- [ ] T021 [P] Review `AirQualityInfoCards` and `AirQualityMetricsScreen` against Meshtastic design standards — verify M3 component usage, typography (labelSmall/labelLarge), TalkBack semantics, touch targets, and units in content descriptions
- [ ] T022 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` — verify only raw sensor numerics are stored/displayed
- [ ] T023 [P] Run constitution-required verification: `./gradlew spotlessApply spotlessCheck detekt :core:model:test :core:data:test :core:database:test :feature:node:test`
- [ ] T024 Validate end-to-end request→response→display loop works: tap request button on node detail and log screen, verify telemetry packet is handled, Node state updates, info cards refresh, log screen appends entry
- [ ] T025 [P] Update `docs/en/user/telemetry-and-sensors.md` to document the Air Quality metrics log screen, info cards, CO₂ severity color-coding, chart usage, and CSV export. Update `last_updated` frontmatter. Verify DocBundleLoader registration if a new page is created.
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on T001 (strings) for label references — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Phase 2 completion (T003T006 provide data flow)
- **User Story 5 (Phase 4)**: Depends on Phase 2 (needs Node model) — can run in parallel with Phase 3
- **User Story 2 (Phase 5)**: Depends on Phase 4 (needs route registered for navigation)
- **User Story 3 (Phase 6)**: Depends on Phase 5 (builds on log screen composable)
- **User Story 4 (Phase 7)**: Depends on Phase 5 (exports from same ViewModel data)
- **Polish (Phase 8)**: Depends on all user story phases complete
### User Story Dependencies
- **US1 (P1)**: Phase 2 only — fully independent of other stories
- **US5 (P1)**: Phase 2 only — fully independent of other stories, can parallel with US1
- **US2 (P2)**: Requires US5 (needs route/navigation to exist)
- **US3 (P2)**: Requires US2 (builds chart into log screen created in US2)
- **US4 (P3)**: Requires US2 (exports data from ViewModel state created in US2)
### Parallel Opportunities
- **Phase 1**: T001 and T002 can run in parallel (different files)
- **Phase 2**: T003 and T004 can run in parallel (different modules); T005 depends on T004; T006 depends on T003
- **Phase 3+4**: US1 (T007T009) and US5 (T010T012) can run in parallel after Phase 2
- **Phase 5+7**: US3 (T016) enum creation can parallel with US2 history implementation
- **Phase 8**: T021, T022, T023 all run in parallel (independent checks)
---
## Parallel Example: Phase 2 (Foundation)
```bash
# Launch independent model + entity changes together:
Task T003: "Add airQualityMetrics field to Node model"
Task T004: "Add air_quality_metrics BLOB column to NodeEntity"
# Then sequential (depends on T004):
Task T005: "Bump database version 38→39"
# Then sequential (depends on T003):
Task T006: "Handle air_quality_metrics in TelemetryPacketHandlerImpl"
```
## Parallel Example: MVP Stories (Phases 3 + 4)
```bash
# After Phase 2 completes, run both P1 stories in parallel:
# Developer A: User Story 1 (info cards)
Task T007: "Create AirQualityInfoCards composable"
Task T008: "Apply CO₂ severity color"
Task T009: "Integrate into node detail screen"
# Developer B: User Story 5 (navigation)
Task T010: "Add NodeDetailRoute.AirQualityMetrics"
Task T011: "Add AIR_QUALITY to LogsType enum"
Task T012: "Register route in NodesNavigation.kt"
```
---
## Implementation Strategy
### MVP First (User Stories 1 + 5 Only)
1. Complete Phase 1: Setup (strings + CO₂ utility)
2. Complete Phase 2: Foundational (model + entity + migration + handler)
3. Complete Phase 3: User Story 1 (info cards on node detail)
4. Complete Phase 4: User Story 5 (navigation plumbing)
5. **STOP and VALIDATE**: Info cards show live data, navigation works
6. Run `./gradlew spotlessCheck detekt :core:model:test :core:data:test :core:database:test :feature:node:test`
### Incremental Delivery
1. Setup + Foundational → Data pipeline works end-to-end
2. Add US1 + US5 → MVP: View readings + navigate (deployable)
3. Add US2 → History browsing with time frame filter
4. Add US3 → Charts for trend analysis
5. Add US4 → CSV export for external tools
6. Polish → Design review, privacy check, full CI validation
---
## Notes
- All new code in `commonMain` source sets (Constitution I)
- Follow `EnvironmentMetrics` patterns exactly (info cards, log screen, CSV export)
- `Co2Severity` thresholds per design/issues/53: Good ≤1000, Stuffy 10002000, Poor 20005000, Unsafe 5000+, Evacuate 30000+
- Chart style: thin lines only, dot marker at selection point only (design/issues/53)
- Gas resistance intentionally excluded (already shown as IAQ in Environment metrics)
- Proto submodule is read-only — `AirQualityMetrics` message already exists upstream
- Request button infrastructure already exists (TelemetricActionsSection + CommandSenderImpl) — only the response handler is new