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:
- Consumer Demand: Heightened health awareness post-pandemic
- IoT Integration: Smart homes need environmental sensors
- Regulatory Push: Building codes increasingly require air quality monitoring
- 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
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
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;
}
}
Business Development Pathway
Phase 1: Prototype Validation (Months 1-3)
- Build 5-10 working prototypes using the code above
- Test in various environments: Homes, offices, schools
- Collect data on sensor accuracy and reliability
- Gather user feedback on form factor and usability
- Refine design based on findings
Phase 2: Product Development (Months 4-8)
- Design custom PCB to reduce size and cost
- Source components in bulk (50-100 units)
- Develop injection-molded enclosure
- Create mobile app for data visualization (consider Flutter or React Native)
- 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
- Hardware Sales: Direct unit sales
- Subscription Services: Premium features, historical data
- B2B Solutions: White-label devices for HVAC companies
- 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
}
}
Production Scaling
- Small Batch (100 units): Manual assembly, 3D-printed enclosures
- Medium Batch (1,000 units): Contract manufacturer, injection molding
- Large Scale (10,000+ units): Established supply chain, automated testing
Regulatory and Compliance
Key Certifications
- FCC/CE: Electromagnetic compatibility
- RoHS: Restriction of hazardous substances
- Calibration Standards: NIST-traceable if claiming high accuracy
- 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
}
}
Marketing and Sales Strategy
Target Customer Segments
- Health-Conscious Families: Focus on child health, allergy relief
- Smart Home Enthusiasts: Integration with Home Assistant, Apple HomeKit
- Small Businesses: Restaurants, gyms, offices seeking wellness certifications
- Educational Institutions: Schools monitoring classroom air quality
Go-to-Market Channels
- Direct Sales: Website with educational content
- Marketplaces: Amazon, Tindie (for developer edition)
- B2B Partnerships: HVAC companies, building management systems
- Health Professionals: Doctors, allergists as referral sources
Future Enhancements and Roadmap
Technical Roadmap
- Q2 2024: Add VOC sensing (SGP30), improve calibration
- Q3 2024: Implement low-power modes, battery operation
- Q4 2024: Multi-room synchronization, advanced analytics
- Q1 2025: Machine learning for predictive air quality
Business Expansion
- Geographic Expansion: Localize for Asian markets with higher pollution
- Vertical Integration: Develop proprietary sensor technology
- Platform Strategy: Become air quality data aggregator
- 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)