commit 31127ff783980ba5bf542622f56556b08a47cb1d Author: PaulVua Date: Thu Nov 6 11:46:39 2025 +0100 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89cc49c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.pio +.vscode/.browse.c_cpp.db* +.vscode/c_cpp_properties.json +.vscode/launch.json +.vscode/ipch diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..080e70d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "platformio.platformio-ide" + ], + "unwantedRecommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ba053a5 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is a PlatformIO-based ESP32 Arduino project for communicating with a NextPM (NPM) particulate matter sensor over serial connection. The project reads PM1, PM2.5, and PM10 measurements in both µg/m³ and particles per liter (pcs/L). + +## Build and Development Commands + +**Build the project:** +```bash +pio run +``` + +**Upload to ESP32:** +```bash +pio run --target upload +``` + +**Monitor serial output (115200 baud):** +```bash +pio device monitor +``` + +**Build and upload in one command:** +```bash +pio run --target upload && pio device monitor +``` + +**Clean build files:** +```bash +pio run --target clean +``` + +## Hardware Configuration + +- **Board:** ESP32 dev board +- **Serial Configuration:** + - Debug serial: `Serial` (USB, 115200 baud) + - NPM serial: `Serial1` (UART1, 115200 baud, 8E1 parity) + - RX Pin: GPIO 39 + - TX Pin: GPIO 32 + +## Architecture + +### Serial Communication Protocol + +The NextPM sensor uses a binary protocol with checksums. Communication follows a request-response pattern with specific message formats: + +**Message Structure:** +- Header (2 bytes): Command identifier (e.g., `0x81 0x16`) +- State (1 byte): Device state bits +- Data (variable): Response payload +- Checksum (1 byte): Sum of all bytes mod 0x100 must equal 0 + +**State Machine Enums:** +The code uses multiple enums (`NPM_waiting_for_4`, `NPM_waiting_for_8`, `NPM_waiting_for_16`) to track parsing state for different response types (4, 8, or 16 bytes total). + +### Key Functions + +**Initialization (`powerOnTestNPM`):** +- Waits 15 seconds for sensor startup +- Queries sensor state +- Starts sensor if stopped +- Reads firmware version and temperature/humidity + +**Measurement (`fetchSensorNPM_1min`):** +- Sends concentration command (1-minute averaged readings) +- Parses 16-byte response containing 6 values: + - N1, N2.5, N10 (particle counts as pcs/L) + - PM1, PM2.5, PM10 (mass concentrations as µg/m³ × 10) +- Validates checksum before accepting data + +**Loop Behavior:** +- Reads sensor every 60 seconds (configurable via `interval` constant) +- Non-blocking timing using `millis()` + +### File Organization + +- `src/main.cpp`: Main application logic, NPM protocol implementation, sensor state machine +- `src/utils.h`: Protocol constants, enums, checksum validation prototypes +- `src/utils.cpp`: Utility functions for checksum validation and NPM command transmission +- `platformio.ini`: PlatformIO configuration for ESP32 + +## Important Notes + +- The sensor requires a 3-second timeout for serial responses +- All particle concentration values from the sensor are scaled (PM values by 10, temperature/humidity by 100) +- The `nextpmconnected` flag prevents infinite loops if sensor disconnects +- Comments are in French in some sections of the code diff --git a/include/README b/include/README new file mode 100644 index 0000000..630164d --- /dev/null +++ b/include/README @@ -0,0 +1,37 @@ + +This directory is intended for project header files. + +A header file is a file containing C declarations and macro definitions +to be shared between several project source files. You request the use of a +header file in your project source file (C, C++, etc) located in `src` folder +by including it, with the C preprocessing directive `#include'. + +```src/main.c + +#include "header.h" + +int main (void) +{ + ... +} +``` + +Including a header file produces the same results as copying the header file +into each source file that needs it. Such copying would be time-consuming +and error-prone. With a header file, the related declarations appear +in only one place. If they need to be changed, they can be changed in one +place, and programs that include the header file will automatically use the +new version when next recompiled. The header file eliminates the labor of +finding and changing all the copies as well as the risk that a failure to +find one copy will result in inconsistencies within a program. + +In C, the convention is to give header files names that end with `.h'. + +Read more about using header files in official GCC documentation: + +* Include Syntax +* Include Operation +* Once-Only Headers +* Computed Includes + +https://gcc.gnu.org/onlinedocs/cpp/Header-Files.html diff --git a/lib/README b/lib/README new file mode 100644 index 0000000..8d3ee2a --- /dev/null +++ b/lib/README @@ -0,0 +1,46 @@ + +This directory is intended for project specific (private) libraries. +PlatformIO will compile them to static libraries and link into the executable file. + +The source code of each library should be placed in a separate directory +("lib/your_library_name/[Code]"). + +For example, see the structure of the following example libraries `Foo` and `Bar`: + +|--lib +| | +| |--Bar +| | |--docs +| | |--examples +| | |--src +| | |- Bar.c +| | |- Bar.h +| | |- library.json (optional. for custom build options, etc) https://docs.platformio.org/page/librarymanager/config.html +| | +| |--Foo +| | |- Foo.c +| | |- Foo.h +| | +| |- README --> THIS FILE +| +|- platformio.ini +|--src + |- main.c + +Example contents of `src/main.c` using Foo and Bar: +``` +#include +#include + +int main (void) +{ + ... +} + +``` + +The PlatformIO Library Dependency Finder will find automatically dependent +libraries by scanning project source files. + +More information about PlatformIO Library Dependency Finder +- https://docs.platformio.org/page/librarymanager/ldf.html diff --git a/platformio.ini b/platformio.ini new file mode 100644 index 0000000..25ecf3a --- /dev/null +++ b/platformio.ini @@ -0,0 +1,15 @@ +; PlatformIO Project Configuration File +; +; Build options: build flags, source filter +; Upload options: custom upload port, speed and extra flags +; Library options: dependencies, extra library storages +; Advanced options: extra scripting +; +; Please visit documentation for the other options and examples +; https://docs.platformio.org/page/projectconf.html + +[env:esp32] +platform = espressif32 +board = esp32dev +monitor_speed = 115200 +framework = arduino diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..e57ab47 --- /dev/null +++ b/src/main.cpp @@ -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 + +/***************************************************************** + * 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 +} diff --git a/test/README b/test/README new file mode 100644 index 0000000..b0416ad --- /dev/null +++ b/test/README @@ -0,0 +1,11 @@ + +This directory is intended for PlatformIO Test Runner and project tests. + +Unit Testing is a software testing method by which individual units of +source code, sets of one or more MCU program modules together with associated +control data, usage procedures, and operating procedures, are tested to +determine whether they are fit for use. Unit testing finds problems early +in the development cycle. + +More information about PlatformIO Unit Testing: +- https://docs.platformio.org/en/latest/advanced/unit-testing/index.html