Files
esp32_NPM_only/src/main.cpp
PaulVua 31127ff783 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>
2025-11-06 11:46:39 +01:00

319 lines
12 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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
}