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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal 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
10
.vscode/extensions.json
vendored
Normal 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
91
CLAUDE.md
Normal 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
37
include/README
Normal 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
46
lib/README
Normal 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
15
platformio.ini
Normal 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
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
|
||||
}
|
||||
11
test/README
Normal file
11
test/README
Normal 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
|
||||
Reference in New Issue
Block a user