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:
PaulVua
2025-11-06 11:46:39 +01:00
commit 31127ff783
8 changed files with 533 additions and 0 deletions

318
src/main.cpp Normal file
View 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
}