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

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
.pio
.vscode/.browse.c_cpp.db*
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch

10
.vscode/extensions.json vendored Normal file
View File

@@ -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"
]
}

91
CLAUDE.md Normal file
View File

@@ -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

37
include/README Normal file
View File

@@ -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

46
lib/README Normal file
View File

@@ -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 <Foo.h>
#include <Bar.h>
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

15
platformio.ini Normal file
View File

@@ -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

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
}

11
test/README Normal file
View File

@@ -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