/* * ESP32 NextPM Particulate Matter Sensor Reader * * This program reads PM (Particulate Matter) concentration data from a NextPM sensor * connected to an ESP32 via serial communication (UART). * * The sensor measures three types of particulate matter: * - PM1.0: Particles with diameter < 1.0 micrometers * - PM2.5: Particles with diameter < 2.5 micrometers * - PM10: Particles with diameter < 10 micrometers * * Values are reported in µg/m³ (micrograms per cubic meter) * Readings are averaged over 60 seconds and updated every 10 seconds */ #include /***************************************************************** * HARDWARE CONFIGURATION *****************************************************************/ // Serial port definitions // The ESP32 has multiple serial ports. We use: // - Serial: USB connection for debugging/output (Serial Monitor) // - Serial1: UART1 for communicating with the NextPM sensor #define serialNPM (Serial1) // GPIO pins for sensor communication #define PM_SERIAL_RX 39 // Receive data from sensor on GPIO 39 #define PM_SERIAL_TX 32 // Transmit data to sensor on GPIO 32 /***************************************************************** * TIMING CONFIGURATION *****************************************************************/ // Variables for non-blocking timing // We use millis() instead of delay() to avoid blocking the program unsigned long previousMillis = 0; // Stores the last time we read the sensor const long interval = 10000; // Read every 10 seconds (10000 milliseconds) /***************************************************************** * DATA STORAGE *****************************************************************/ // Global variables to store the latest PM measurements float pm1_ugm3 = 0.0; // PM1.0 concentration in µg/m³ float pm25_ugm3 = 0.0; // PM2.5 concentration in µg/m³ float pm10_ugm3 = 0.0; // PM10 concentration in µg/m³ /***************************************************************** * HELPER FUNCTIONS *****************************************************************/ /** * Validate the checksum of a 16-byte message from the sensor * * The NextPM sensor uses a simple checksum algorithm for data integrity: * Sum all 16 bytes together, and the result modulo 256 should equal 0. * * @param data Array of 16 bytes to validate * @return true if checksum is valid, false otherwise */ bool checksum_valid(const uint8_t (&data)[16]) { uint8_t sum = 0; // Add up all bytes in the message for (int i = 0; i < 16; i++) { sum += data[i]; } // Valid if sum modulo 256 equals 0 // (sum % 0x100 is the same as sum % 256) return (sum % 0x100 == 0); } /** * Send the command to request concentration data from the sensor * * The command is a 3-byte sequence: * - 0x81: Command prefix (tells sensor a command is coming) * - 0x12: Concentration request command code * - 0x6D: Checksum byte (makes the sum of all 3 bytes valid) * * This requests PM values averaged over 1 minute */ void send_concentration_command() { const uint8_t cmd[] = {0x81, 0x12, 0x6D}; serialNPM.write(cmd, 3); // Send all 3 bytes to the sensor } /** * Print raw byte data in hexadecimal format for debugging * * This is useful when troubleshooting communication issues with the sensor. * Example output: Raw data: 0x81, 0x12, 0x00, 0x1A, ... * * @param data Array of bytes to print * @param size Number of bytes in the array */ void print_data(uint8_t data[], size_t size) { Serial.print("Raw data: "); for (size_t i = 0; i < size; i++) { Serial.print("0x"); if (data[i] < 0x10) Serial.print("0"); // Add leading zero for single-digit hex Serial.print(data[i], HEX); if (i != size - 1) Serial.print(", "); // Add comma between bytes } Serial.println(); } /***************************************************************** * MAIN SENSOR READING FUNCTION *****************************************************************/ /** * Read and parse PM concentration data from the NextPM sensor * * This function performs the following steps: * 1. Send a command to the sensor requesting concentration data * 2. Wait for and receive the sensor's response (16 bytes total) * 3. Validate the data using checksum verification * 4. Extract and convert PM values * 5. Display results on the serial monitor * * The sensor response format (16 bytes): * [0-1] Header: 0x81 0x12 * [2] State: Sensor status byte * [3-14] Data: 6 values × 2 bytes each (N1, N2.5, N10, PM1, PM2.5, PM10) * [15] Checksum: Validation byte */ void read_concentration() { Serial.println("\n--- Reading NextPM Concentrations ---"); // STEP 1: Send command to sensor send_concentration_command(); // STEP 2: Wait for response with timeout protection // The sensor typically responds within 1 second, but we wait up to 3 seconds unsigned long timeout = millis(); while (!serialNPM.available() && millis() - timeout < 3000) { delay(10); // Small delay to avoid hogging the CPU } // Check if we received any data if (!serialNPM.available()) { Serial.println("ERROR: No response from sensor"); return; // Exit function if no response } // STEP 3: Look for the response header (0x81 0x12) // The header identifies this as a concentration response message const uint8_t header[2] = {0x81, 0x12}; if (!serialNPM.find(header, 2)) { Serial.println("ERROR: Header not found"); return; } // STEP 4: Read the state byte // This byte contains status information about the sensor uint8_t state; if (serialNPM.readBytes(&state, 1) != 1) { Serial.println("ERROR: Failed to read state"); return; } // STEP 5: Read the 12 data bytes // These contain 6 values (2 bytes each): N1, N2.5, N10, PM1, PM2.5, PM10 uint8_t data[12]; if (serialNPM.readBytes(data, 12) != 12) { Serial.println("ERROR: Failed to read data"); return; } // STEP 6: Read the checksum byte // This is used to verify data integrity uint8_t checksum; if (serialNPM.readBytes(&checksum, 1) != 1) { Serial.println("ERROR: Failed to read checksum"); return; } // STEP 7: Reconstruct the complete 16-byte message for validation uint8_t full_msg[16]; full_msg[0] = header[0]; // 0x81 full_msg[1] = header[1]; // 0x12 full_msg[2] = state; // State byte memcpy(&full_msg[3], data, 12); // Copy 12 data bytes full_msg[15] = checksum; // Checksum byte // STEP 8: Validate the checksum if (!checksum_valid(full_msg)) { Serial.println("ERROR: Invalid checksum"); print_data(full_msg, 16); // Print raw data for debugging return; } // STEP 9: Parse the concentration data from the 12-byte response // // Data layout in the 12 bytes: // Bytes 0-1: N1.0 particle count (we skip this) // Bytes 2-3: N2.5 particle count (we skip this) // Bytes 4-5: N10 particle count (we skip this) // Bytes 6-7: PM1.0 concentration ← We extract this // Bytes 8-9: PM2.5 concentration ← We extract this // Bytes 10-11: PM10 concentration ← We extract this // // Each value is a 16-bit unsigned integer (2 bytes) in big-endian format // (high byte first, then low byte) // Extract PM1.0 concentration (bytes 6-7) // word() combines two bytes into a 16-bit integer (high byte, low byte) uint16_t pm1_raw = word(data[6], data[7]); // Extract PM2.5 concentration (bytes 8-9) uint16_t pm25_raw = word(data[8], data[9]); // Extract PM10 concentration (bytes 10-11) uint16_t pm10_raw = word(data[10], data[11]); // STEP 10: Convert raw values to actual concentrations // The sensor sends values multiplied by 10 to preserve one decimal place // Example: A reading of 25.3 µg/m³ is sent as 253 // So we divide by 10.0 to get the real value in µg/m³ pm1_ugm3 = pm1_raw / 10.0; pm25_ugm3 = pm25_raw / 10.0; pm10_ugm3 = pm10_raw / 10.0; // STEP 11: Display the results to the serial monitor Serial.println("\n=== Particulate Matter Concentrations ==="); Serial.print("PM1.0: "); Serial.print(pm1_ugm3, 1); // Print with 1 decimal place Serial.println(" µg/m³"); Serial.print("PM2.5: "); Serial.print(pm25_ugm3, 1); Serial.println(" µg/m³"); Serial.print("PM10: "); Serial.print(pm10_ugm3, 1); Serial.println(" µg/m³"); Serial.println("======================================\n"); } /***************************************************************** * ARDUINO SETUP FUNCTION *****************************************************************/ /** * Setup function - runs once when the ESP32 starts or resets * * This function initializes: * 1. USB serial communication for debugging/output * 2. UART serial communication with the NextPM sensor * 3. Waits for the sensor to power up and stabilize */ void setup() { // ===== Initialize USB Serial for debugging ===== // This allows us to see output in the Arduino Serial Monitor Serial.begin(115200); // 115200 baud rate delay(2000); // Wait 2 seconds for serial connection to stabilize // Print startup message Serial.println("\n\n=== NextPM Sensor Reader ==="); Serial.println("Simplified version - Concentration readings only"); Serial.println("Averaging: 60 seconds, Update: every 10 seconds\n"); // ===== Initialize Sensor Serial Communication ===== // SERIAL_8E1 means: // - 8 data bits // - Even parity (error checking bit) // - 1 stop bit // This is the format required by the NextPM sensor serialNPM.begin(115200, SERIAL_8E1, PM_SERIAL_RX, PM_SERIAL_TX); // Set timeout for serial read operations to 3 seconds // If the sensor doesn't respond within 3 seconds, read operations will fail serialNPM.setTimeout(3000); // ===== Wait for sensor to power up ===== // The NextPM sensor requires about 15 seconds to initialize after power-on // This is critical - reading too early will fail Serial.println("Waiting 15 seconds for sensor initialization..."); delay(15000); Serial.println("Sensor ready. Starting measurements...\n"); } /***************************************************************** * ARDUINO MAIN LOOP FUNCTION *****************************************************************/ /** * Loop function - runs repeatedly after setup() completes * * This function implements non-blocking timing to read the sensor * every 10 seconds without using delay(), which would freeze the program. * * Non-blocking timing allows the ESP32 to do other tasks between readings * (e.g., responding to network requests, processing interrupts, etc.) */ void loop() { // Get the current time in milliseconds since the ESP32 started // millis() wraps around after ~49 days, but our math handles this correctly unsigned long currentMillis = millis(); // Check if enough time has passed since the last reading // interval = 10000 milliseconds = 10 seconds if (currentMillis - previousMillis >= interval) { // Update the timestamp for the next reading previousMillis = currentMillis; // Read and display sensor data read_concentration(); } // The loop continues to run, checking the time on every iteration // This is much more efficient than using delay() which would block execution }