Initial commit: ESP32 NextPM sensor reader
Educational version with comprehensive comments for students. - Reads PM1.0, PM2.5, and PM10 concentrations from NextPM sensor - Uses UART serial communication with checksum validation - Non-blocking timing with millis() - Removed particle counts, kept only mass concentrations - Extensively commented to help students understand serial protocols 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
318
src/main.cpp
Normal file
318
src/main.cpp
Normal file
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* 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 <Arduino.h>
|
||||
|
||||
/*****************************************************************
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user