DEV Community

Mayuresh Smita Suresh
Mayuresh Smita Suresh Subscriber

Posted on

India’s Air pollution is a serious problem, so here is how to build simple AQI monitor using Rust

Building a Professional Air Quality Monitor with Embedded Rust: From Prototype to Product

Introduction: The Air We Breathe Matters

Air quality is no longer just an environmental concern—it's a critical health and wellness issue affecting millions worldwide. With increasing urbanization and awareness of indoor air pollution, the demand for accurate, affordable air quality monitoring has skyrocketed. Enter the world of embedded systems and Rust programming, where we can build sophisticated, reliable monitoring devices that rival commercial products.

This guide walks you through creating a professional-grade air quality monitor that measures PM2.5, carbon monoxide (CO), temperature, humidity, and calculates the Air Quality Index (AQI)—all in one compact device. What makes this approach unique is our use of Rust, a systems programming language that offers memory safety, concurrency, and performance perfect for embedded applications.

Why Rust for Embedded Air Quality Monitoring?

Traditional embedded development often uses C or C++, but Rust brings compelling advantages:

· Memory Safety Without Garbage Collection: Critical for resource-constrained embedded systems
· Fearless Concurrency: Safely handle multiple sensor readings and communication protocols
· Zero-Cost Abstractions: Write high-level code without performance penalties
· Growing Embedded Ecosystem: With frameworks like embedded-hal, no_std support, and microcontroller-specific HALs

Market Opportunity: More Than Just a Hobby Project

The global air quality monitor market is projected to reach $7.3 billion by 2027, growing at a CAGR of 6.5%. But beyond the numbers, there's a clear business case:

  1. Consumer Demand: Heightened health awareness post-pandemic
  2. IoT Integration: Smart homes need environmental sensors
  3. Regulatory Push: Building codes increasingly require air quality monitoring
  4. Data Services: Aggregated air quality data has commercial value

Hardware Architecture: Choosing the Right Components

Core Components Table

Component Recommended Model Key Specifications Approx. Cost
Microcontroller ESP32-C3-DevKitM-1 RISC-V, WiFi, Bluetooth, low power $8-12
PM2.5 Sensor Plantower PMS5003 Laser scattering, 0.3-10μm range, UART $18-25
CO Sensor Winsen MH-Z19C NDIR technology, 0-5000ppm, UART/PWM $25-35
Temp/Humidity Sensirion SHT40 ±1.8% RH, ±0.2°C, I2C $6-10
Display SSD1306 OLED 128x64 pixels, I2C, low power $6-9
Enclosure Custom 3D printed Ventilation, mounting points $3-5
Total Cost $66-96

Alternative Options for Different Use Cases

· Budget Option: Use MQ-135 for general air quality (less specific than dedicated sensors)
· Industrial Grade: Consider Alphasense OPC-N3 for PM sensing
· All-in-one Modules: Sensirion SCD40 (CO2, temp, humidity) plus separate PM sensor

Complete Rust Implementation

Let's build the complete firmware. First, set up your project:

# Create new cargo project
cargo new aqi-monitor --bin
cd aqi-monitor

# Add embedded Rust targets
rustup target add riscv32imc-unknown-none-elf

# Create .cargo/config.toml
mkdir .cargo
cat > .cargo/config.toml << EOF
[build]
target = "riscv32imc-unknown-none-elf"

[unstable]
build-std = ["core", "alloc"]
build-std-features = ["panic_immediate_abort"]
EOF
Enter fullscreen mode Exit fullscreen mode

Cargo.toml Dependencies

[package]
name = "aqi-monitor"
version = "0.1.0"
edition = "2021"

[dependencies]
esp32c3-hal = { version = "0.10", features = ["rt", "embassy"] }
embedded-hal = "0.2"
embedded-storage = "0.3"
embedded-graphics = "0.7"
ssd1306 = { version = "0.8", features = ["graphics", "builder", "embedded-graphics"] }
heapless = "0.8"
micromath = "2.0"
arrayvec = "0.7"
critical-section = "1.1"
embassy-sync = { version = "0.5", features = ["nightly"] }
embassy-time = { version = "0.3", features = ["nightly"] }

[profile.release]
opt-level = "s"  # Optimize for size
lto = true
codegen-units = 1
Enter fullscreen mode Exit fullscreen mode

Main Application Code

// src/main.rs
#![no_std]
#![no_main]
#![feature(type_alias_impl_trait)]

use core::fmt::Write;
use embassy_executor::Spawner;
use embassy_time::{Duration, Timer};
use embedded_graphics::{
    mono_font::{ascii::FONT_6X10, MonoTextStyle},
    pixelcolor::BinaryColor,
    prelude::*,
    text::Text,
};
use esp32c3_hal::{
    clock::ClockControl,
    embassy,
    gpio::IO,
    i2c::I2C,
    peripherals::Peripherals,
    prelude::*,
    uart::{config::Config, Uart},
    Rtc,
};
use heapless::String;
use ssd1306::{prelude::*, I2CDisplayInterface, Ssd1306};

// Sensor data structures
#[derive(Debug, Clone, Copy)]
pub struct AirQualityData {
    pub pm25: f32,      // μg/m³
    pub pm10: f32,      // μg/m³
    pub co: f32,        // ppm
    pub temperature: f32, // °C
    pub humidity: f32,   // %
    pub aqi: u16,       // Air Quality Index
    pub aqi_category: &'static str,
}

impl AirQualityData {
    pub fn new() -> Self {
        Self {
            pm25: 0.0,
            pm10: 0.0,
            co: 0.0,
            temperature: 0.0,
            humidity: 0.0,
            aqi: 0,
            aqi_category: "Unknown",
        }
    }

    // Calculate US EPA AQI for PM2.5
    pub fn calculate_aqi(&mut self) {
        let pm25 = self.pm25;

        // EPA AQI breakpoints for PM2.5 (24-hour average)
        let breakpoints = [
            (0.0, 12.0, 0, 50),     // Good
            (12.1, 35.4, 51, 100),  // Moderate
            (35.5, 55.4, 101, 150), // Unhealthy for Sensitive Groups
            (55.5, 150.4, 151, 200), // Unhealthy
            (150.5, 250.4, 201, 300), // Very Unhealthy
            (250.5, 500.4, 301, 500), // Hazardous
        ];

        for (bp_low, bp_high, i_low, i_high) in breakpoints.iter() {
            if pm25 >= *bp_low && pm25 <= *bp_high {
                self.aqi = (((i_high - i_low) as f32 / (bp_high - bp_low)) 
                           * (pm25 - bp_low) + *i_low as f32).round() as u16;

                // Set category
                self.aqi_category = match *i_low {
                    0 => "Good",
                    51 => "Moderate",
                    101 => "Unhealthy(SG)",
                    151 => "Unhealthy",
                    201 => "Very Unhealthy",
                    301 => "Hazardous",
                    _ => "Unknown",
                };
                return;
            }
        }

        // If above highest breakpoint
        self.aqi = 500;
        self.aqi_category = "Hazardous";
    }
}

// PMS5003 PM Sensor Driver
pub struct PMS5003<'a> {
    uart: Uart<'a>,
    buffer: [u8; 32],
}

impl<'a> PMS5003<'a> {
    pub fn new(uart: Uart<'a>) -> Self {
        Self {
            uart,
            buffer: [0; 32],
        }
    }

    pub async fn read(&mut self) -> Option<(f32, f32)> {
        // Look for start bytes 0x42 0x4D
        let mut state = 0;
        let mut idx = 0;

        // Clear buffer
        self.buffer = [0; 32];

        loop {
            let mut byte = [0];
            if self.uart.read(&mut byte).await.is_ok() {
                match state {
                    0 if byte[0] == 0x42 => {
                        self.buffer[idx] = byte[0];
                        idx += 1;
                        state = 1;
                    }
                    1 if byte[0] == 0x4D => {
                        self.buffer[idx] = byte[0];
                        idx += 1;
                        state = 2;
                    }
                    2 if idx < 32 => {
                        self.buffer[idx] = byte[0];
                        idx += 1;

                        // Full frame received (32 bytes)
                        if idx == 32 {
                            // Verify checksum
                            let checksum = u16::from_be_bytes([
                                self.buffer[30], 
                                self.buffer[31]
                            ]);

                            let calculated: u16 = self.buffer[0..30]
                                .iter()
                                .map(|&b| b as u16)
                                .sum();

                            if checksum == calculated {
                                // Parse PM2.5 and PM10 concentrations
                                let pm25_std = u16::from_be_bytes([
                                    self.buffer[4], 
                                    self.buffer[5]
                                ]) as f32;
                                let pm10_std = u16::from_be_bytes([
                                    self.buffer[6], 
                                    self.buffer[7]
                                ]) as f32;

                                // Convert to μg/m³
                                let pm25 = pm25_std;
                                let pm10 = pm10_std;

                                return Some((pm25, pm10));
                            }
                            // Reset for next frame
                            state = 0;
                            idx = 0;
                        }
                    }
                    _ => {
                        // Reset state machine
                        state = 0;
                        idx = 0;
                    }
                }
            }

            // Small delay between bytes
            Timer::after(Duration::from_millis(1)).await;
        }
    }
}

// MH-Z19C CO Sensor Driver
pub struct MHZ19C<'a> {
    uart: Uart<'a>,
}

impl<'a> MHZ19C<'a> {
    pub fn new(uart: Uart<'a>) -> Self {
        Self { uart }
    }

    pub async fn read_co(&mut self) -> Option<f32> {
        // Command to read CO concentration: 0xFF 0x01 0x86 0x00 0x00 0x00 0x00 0x00 0x79
        let command: [u8; 9] = [0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79];

        // Send command
        self.uart.write(&command).await.ok()?;

        // Wait for response
        Timer::after(Duration::from_millis(100)).await;

        // Read response (9 bytes)
        let mut response = [0u8; 9];
        if self.uart.read(&mut response).await.is_ok() {
            // Verify checksum
            let checksum = response[8];
            let calculated: u8 = response[1..8]
                .iter()
                .fold(0u8, |sum, &b| sum.wrapping_add(b));

            if checksum == calculated.wrapping_neg().wrapping_add(1) {
                // Parse CO concentration in ppm
                let co_high = response[2] as u16;
                let co_low = response[3] as u16;
                let co = ((co_high << 8) | co_low) as f32;

                return Some(co);
            }
        }

        None
    }
}

#[embassy_executor::main]
async fn main(spawner: Spawner) {
    // Initialize peripherals
    let peripherals = Peripherals::take();
    let mut system = peripherals.SYSTEM.split();
    let clocks = ClockControl::boot_defaults(system.clock_control).freeze();

    // Initialize RTC
    let mut rtc = Rtc::new(peripherals.RTC_CNTL);
    rtc.rwdt.disable();

    // Initialize embassy
    embassy::init(&clocks);

    // Setup I/O pins
    let io = IO::new(peripherals.GPIO, peripherals.IO_MUX);

    // I2C for OLED display
    let i2c = I2C::new(
        peripherals.I2C0,
        io.pins.gpio6,
        io.pins.gpio7,
        400u32.kHz(),
        &mut system.peripheral_clock_control,
    );

    // UART1 for PMS5003 PM sensor
    let uart1 = Uart::new_with_config(
        peripherals.UART1,
        Config::default().baudrate(9600),
        Some(io.pins.gpio4),
        Some(io.pins.gpio5),
        &mut system.peripheral_clock_control,
    );

    // UART2 for MH-Z19C CO sensor
    let uart2 = Uart::new_with_config(
        peripherals.UART2,
        Config::default().baudrate(9600),
        Some(io.pins.gpio8),
        Some(io.pins.gpio9),
        &mut system.peripheral_clock_control,
    );

    // Initialize sensors
    let mut pm_sensor = PMS5003::new(uart1);
    let mut co_sensor = MHZ19C::new(uart2);

    // Initialize OLED display
    let interface = I2CDisplayInterface::new(i2c);
    let mut display = Ssd1306::new(
        interface,
        DisplaySize128x64,
        DisplayRotation::Rotate0,
    ).into_buffered_graphics_mode();
    display.init().unwrap();

    // Main monitoring loop
    let mut aq_data = AirQualityData::new();
    let mut display_string: String<64> = String::new();

    loop {
        // Read from PM sensor
        if let Some((pm25, pm10)) = pm_sensor.read().await {
            aq_data.pm25 = pm25;
            aq_data.pm10 = pm10;
        }

        // Read from CO sensor
        if let Some(co) = co_sensor.read_co().await {
            aq_data.co = co;
        }

        // For now, use simulated temperature/humidity
        // In a real implementation, you would read from SHT40 here
        aq_data.temperature = 22.5; // Example value
        aq_data.humidity = 45.0;    // Example value

        // Calculate AQI
        aq_data.calculate_aqi();

        // Update display
        display.clear();

        // Prepare display text
        display_string.clear();
        write!(
            &mut display_string,
            "PM2.5: {:.1} μg/m³\nCO: {:.1} ppm\nAQI: {} ({})\nT: {:.1}°C H: {:.1}%",
            aq_data.pm25,
            aq_data.co,
            aq_data.aqi,
            aq_data.aqi_category,
            aq_data.temperature,
            aq_data.humidity
        ).unwrap();

        // Create text style
        let text_style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);

        // Display text
        Text::new(&display_string, Point::new(0, 10), text_style)
            .draw(&mut display)
            .unwrap();

        display.flush().unwrap();

        // Log data (in real implementation, send via WiFi)
        esp_println::println!(
            "PM2.5: {:.1}, PM10: {:.1}, CO: {:.1}, AQI: {}, Temp: {:.1}, Humidity: {:.1}",
            aq_data.pm25,
            aq_data.pm10,
            aq_data.co,
            aq_data.aqi,
            aq_data.temperature,
            aq_data.humidity
        );

        // Wait before next reading (e.g., 10 seconds)
        Timer::after(Duration::from_secs(10)).await;
    }
}
Enter fullscreen mode Exit fullscreen mode

Business Development Pathway

Phase 1: Prototype Validation (Months 1-3)

  1. Build 5-10 working prototypes using the code above
  2. Test in various environments: Homes, offices, schools
  3. Collect data on sensor accuracy and reliability
  4. Gather user feedback on form factor and usability
  5. Refine design based on findings

Phase 2: Product Development (Months 4-8)

  1. Design custom PCB to reduce size and cost
  2. Source components in bulk (50-100 units)
  3. Develop injection-molded enclosure
  4. Create mobile app for data visualization (consider Flutter or React Native)
  5. Implement cloud backend for data storage and analytics

Phase 3: Market Entry (Months 9-12)

Product Tier Features Target Price Target Market
Basic Local display, USB power $99 Health-conscious consumers
Pro WiFi, app, data history $149 Smart home enthusiasts
Enterprise Multi-room, API, alerts $299+ Offices, schools, hotels

Revenue Streams

  1. Hardware Sales: Direct unit sales
  2. Subscription Services: Premium features, historical data
  3. B2B Solutions: White-label devices for HVAC companies
  4. Data Licensing: Aggregated, anonymized air quality data

Manufacturing Considerations

Bill of Materials Optimization

// Example cost optimization calculation
struct ManufacturingCost {
    components: f32,
    pcb_assembly: f32,
    enclosure: f32,
    testing: f32,
    packaging: f32,
    overhead: f32,
}

impl ManufacturingCost {
    fn calculate_margin(&self, retail_price: f32) -> f32 {
        let total_cost = self.total();
        (retail_price - total_cost) / retail_price * 100.0
    }

    fn total(&self) -> f32 {
        self.components + self.pcb_assembly + self.enclosure 
        + self.testing + self.packaging + self.overhead
    }
}
Enter fullscreen mode Exit fullscreen mode

Production Scaling

  1. Small Batch (100 units): Manual assembly, 3D-printed enclosures
  2. Medium Batch (1,000 units): Contract manufacturer, injection molding
  3. Large Scale (10,000+ units): Established supply chain, automated testing

Regulatory and Compliance

Key Certifications

  1. FCC/CE: Electromagnetic compatibility
  2. RoHS: Restriction of hazardous substances
  3. Calibration Standards: NIST-traceable if claiming high accuracy
  4. Privacy Compliance: GDPR for European customers

Sensor Calibration

// Example calibration structure
struct SensorCalibration {
    pm25_offset: f32,
    pm25_slope: f32,
    co_offset: f32,
    co_slope: f32,
    temperature_compensation: [[f32; 2]; 10], // Lookup table
    humidity_compensation: [[f32; 2]; 10],
}

impl SensorCalibration {
    fn apply(&self, raw_data: &mut AirQualityData) {
        // Apply calibration coefficients
        raw_data.pm25 = raw_data.pm25 * self.pm25_slope + self.pm25_offset;
        raw_data.co = raw_data.co * self.co_slope + self.co_offset;

        // Apply temperature/humidity compensation
        // ... implementation based on characterization data
    }
}
Enter fullscreen mode Exit fullscreen mode

Marketing and Sales Strategy

Target Customer Segments

  1. Health-Conscious Families: Focus on child health, allergy relief
  2. Smart Home Enthusiasts: Integration with Home Assistant, Apple HomeKit
  3. Small Businesses: Restaurants, gyms, offices seeking wellness certifications
  4. Educational Institutions: Schools monitoring classroom air quality

Go-to-Market Channels

  1. Direct Sales: Website with educational content
  2. Marketplaces: Amazon, Tindie (for developer edition)
  3. B2B Partnerships: HVAC companies, building management systems
  4. Health Professionals: Doctors, allergists as referral sources

Future Enhancements and Roadmap

Technical Roadmap

  1. Q2 2024: Add VOC sensing (SGP30), improve calibration
  2. Q3 2024: Implement low-power modes, battery operation
  3. Q4 2024: Multi-room synchronization, advanced analytics
  4. Q1 2025: Machine learning for predictive air quality

Business Expansion

  1. Geographic Expansion: Localize for Asian markets with higher pollution
  2. Vertical Integration: Develop proprietary sensor technology
  3. Platform Strategy: Become air quality data aggregator
  4. Partnerships: Insurance companies offering discounts for healthy homes

Conclusion: Building Something That Matters

Creating an air quality monitor with embedded Rust isn't just a technical challenge—it's an opportunity to build a business that genuinely improves people's lives. The combination of Rust's reliability, modern sensor technology, and growing market demand creates a perfect foundation for a successful venture.

Remember that hardware businesses require patience and iteration. Your first prototype won't be perfect, but each iteration brings you closer to a product that customers will love. Focus on solving real problems, listen to user feedback, and don't underestimate the importance of design and user experience.

The air quality monitoring market is still in its early stages, with plenty of room for innovation. By starting with a solid technical foundation in Rust and a clear business plan, you're well-positioned to create a successful product that helps people breathe easier—both literally and figuratively.

Top comments (0)