Mirko Pavleski
Published © GPL3+

ESP8266 Air Quality monitor with Nova PM Sensor SDS011

This device measured both PM10 and PM2.5 particles uses the Nova PM SDS011 Laser Sensor, and has a graphic presentation on a TFT display.

BeginnerFull instructions provided4 hours2,449
ESP8266 Air Quality monitor with Nova PM Sensor SDS011

Things used in this project

Hardware components

ESP8266 ESP-12E
Espressif ESP8266 ESP-12E
×1
SDS011 Nova PM Sensor
×1
TFT Touchscreen, 320x240
TFT Touchscreen, 320x240
×1
Pushbutton Switch, Momentary
Pushbutton Switch, Momentary
×1
Logic Level FET N-Channel
Logic Level FET N-Channel
×1
Through Hole Resistor, 270 ohm
Through Hole Resistor, 270 ohm
×1
Through Hole Resistor, 270 kohm
Through Hole Resistor, 270 kohm
×1
Resistor 10k ohm
Resistor 10k ohm
×1
Through Hole Resistor, 47 kohm
Through Hole Resistor, 47 kohm
×1

Software apps and online services

Arduino IDE
Arduino IDE

Hand tools and fabrication machines

Soldering iron (generic)
Soldering iron (generic)
Solder Wire, Lead Free
Solder Wire, Lead Free

Story

Read more

Schematics

Schematic diagram

Code

Arduno code

Arduino
// SDS011 Air Quality Monitor
// --------------------------
// Optionally, used in conjunction with PC Server/plotter application at (c) vwlowen.co.uk
//
// Based on SDS011 Sensor libray by R. Zschiegner (rz@madavi.de).

#include <SDS011.h>                           //  https://platformio.org/lib/show/1563/SDS011%20sensor%20Library

#include <Adafruit_GFX.h>                     //  https://github.com/adafruit/Adafruit-GFX-Library
#include "Adafruit_ILI9341.h"                 //  https://github.com/adafruit/Adafruit_ILI9341
#include <Fonts/FreeSansBold18pt7b.h  >
#include <SPI.h>

#include <ESP8266WiFi.h>
#include <ESP8266HTTPClient.h>

#include <EEPROM.h>


const char* ssid = "abcdefg";             // Your WiFi SSID.
const char* password = "*****";           // Your WiFi Password.

String serverIP = "192.168.1.3:8802";          // The server IP address and Port number set up in the PC Server/plotter Application.
String deviceId = "air_quality";               // The device ID that the PC server Application will recognize.


//#define TFT_RST                // (Not connected. Pull TFT RST HIGH with 10k resistor)   
//#define TFT_SCLK  D5           // SCLK is explicit and must be connected to D5 (GPIO14)
//#define TFT_MOSI  D7           //  MOSI is explicit and must be connected to D7 (GPIO13)

#define TFT_CS      D3           // GPIO0                
#define TFT_DC      D2           // GPIO4  

#define SAVE_COUNTER 12          // Data is saved to EEPROM every SAVE_COUNTER * SAMPLE_INTERVAL minutes. (1 hour).

#define SAMPLE_INTERVAL  5       // Take air sample every SAMPLE_INTERVAL minutes

#define SAMPLE_SECS  30          // Run fan for SAMPLE_SECONDS, then take air sample

#define SDS_TX       D1          // SDS011 Tx Pin  GPIO 5
#define SDS_RX       D6          // SDS011 Rx Pin  GPIO 12 (Unused IO - **Do Not Use**)

#define SDS011_PWR   D8          // Power control to SDS011 Sensor.
#define SAVE_DATA    D4          //

int loopCount = 0;

Adafruit_ILI9341 tft = Adafruit_ILI9341(TFT_CS, TFT_DC);  // Adafruit TFT library. Create an instance.

SDS011 sdsSensor;                                         // Sensor library - create an instance of the sensor.

String quality;                                           // Define PM2.5 value as LOW, MEDIUM etc (UK Defra scale).
int colour;                                               // Define PM2.5 value as colour (UK Defra scale)
short pm25Array[320];                                     // Array to hold sensor values for the histogram.

float p10, p25;                                           // Variabled for PM10 and MP2.5 data from sensor.
int error;                                                // Confirms valid data from sensor. 0 = error.

short arrayPointer = 15;                                  // Array element currently being written to (16-bit integer)
int yPos;                                                 // Vertical marker for bar chart.

int sleepSeconds;

float volts = 0.0;
const float Vmax = 5.75;                                 // Max voltage can be set here. Depends on resistor tolerances.

void saveData() {
  
  EEPROM.put(0, arrayPointer);
  
  for (int i= 15; i<=319; i++ ) {
     EEPROM.put(i*2, pm25Array[i]);
  }  
  EEPROM.commit();
  
  tft.setCursor(245, 10);
  tft.setTextColor(ILI9341_BLUE, ILI9341_BLACK);
  tft.print("SAVED");
  while (digitalRead(SAVE_DATA) == LOW);
  delay(250);
} 


void plotHistogram() {                                    // Function to re-draw the histogram.
   tft.fillRect(15,120, 319, 90, ILI9341_BLACK);          // Clear plotting area

   byte line;
   for (int i = 15; i <= 319;  i++){
      getTextData25(pm25Array[i] / 10);                   // Get colour corrsponding to each air quality level. Value is stored
                                                          // multiplied by 10 so divide by 10 here to get true value.                        
      line = constrain(sqrt(pm25Array[i]*40), 0, 105);    // Calculate length of line to plot. sqrt compresses higher values.
     tft.drawFastVLine(i, 225 - line, line,  colour);     // Draw vertical line in chosen colour.
   }
}


#define LIGHT_GREEN  0x9FF3                               // Define colours used by UK Defra to specify pollutant bands.
#define MID_GREEN    0x37E0
#define DARK_GREEN   0x3660
#define LIGHT_YELLOW 0xFFE0
#define MID_YELLOW   0xFE60
#define ORANGE       0xFCC0
#define LIGHT_RED    0xFB2C
#define MID_RED      0xF800
#define DARK_RED     0x9800
#define PURPLE       0xC99F


// UK air pollution bands for PM2.5 and PM10 Particles.  
// https://uk-air.defra.gov.uk/air-pollution/daqi?view=more-info&pollutant=pm25#pollutant

int getTextData25(int value) {                                                         // Function sets three global variables: 'Ypos'
  switch (value) {                                                                     // (vertical cursor position), 'colour' &
    case 0 ... 11 : yPos = 100; colour = LIGHT_GREEN; quality = "1 LOW"; break;        // 'quality' and returns half the length of the
    case 12 ... 23 : yPos = 90; colour = MID_GREEN; quality = "2 LOW"; break;          // text string 'quality' whose value is used to
    case 24 ... 35 : yPos = 80; colour = DARK_GREEN; quality = "3 LOW"; break;         // centre justify the text on the display.
    case 36 ... 41 : yPos = 70; colour = LIGHT_YELLOW; quality = "4 MODERATE"; break;
    case 42 ... 47 : yPos = 60; colour = MID_YELLOW; quality = "5 MODERATE"; break;
    case 48 ... 53 : yPos = 50; colour = ORANGE; quality = "6 MODERATE";  break;
    case 54 ... 58 : yPos = 40; colour = LIGHT_RED;  quality = "7 HIGH"; break;
    case 59 ... 64 : yPos = 30; colour = MID_RED;  quality = "8 HIGH"; break;
    case 65 ... 70 : yPos = 20; colour = DARK_RED;  quality = "9 HIGH"; break;  
    case 71 ... 9999: yPos = 10; colour = PURPLE;  quality = "10 VERY HIGH"; break;   
    default: yPos = 10; colour = ILI9341_MAGENTA;  quality = "HAZARDOUS"; break;
  }  
  return (quality.length() / 2) * 6;
}                                         


int getTextDataPM10(int value) {
  switch (value) {
    case 0 ... 16 : colour = LIGHT_GREEN; quality = "1 LOW"; break;
    case 17 ... 33 : colour = MID_GREEN; quality = "2 LOW"; break;
    case 34 ... 50 :  colour = DARK_GREEN; quality = "3 LOW"; break;
    case 51 ... 58 : colour = LIGHT_YELLOW; quality = "4 MODERATE"; break;
    case 59 ... 66 : colour = MID_YELLOW; quality = "5 MODERATE"; break;
    case 67 ... 75 : colour = ORANGE; quality = "6 MODERATE";  break;
    case 76 ... 83 : colour = LIGHT_RED;  quality = "7 HIGH"; break;
    case 84 ... 91 : colour = MID_RED;  quality = "8 HIGH"; break;
    case 92 ... 100 : colour = DARK_RED;  quality = "9 HIGH"; break;  
    case 101 ... 9999: colour = PURPLE;  quality = "10 VERY HIGH"; break;   
    default: colour = ILI9341_MAGENTA;  quality = "HAZARDOUS"; break;
  }
   return (quality.length() / 2) * 6;
}


void setup() {

  pinMode(SDS011_PWR, OUTPUT); 
  pinMode(SAVE_DATA, INPUT_PULLUP);
  pinMode(A0, INPUT); 

  EEPROM.begin(1000);
  

  //-- The following block of code tests if the EEPROM has been 'prepared'  ----
  //-- with all zeroes and clears it if necessary.------------------------------ 
   
  bool eraseFlag = false;
  for (int i = 2; i< 30; i++ ) {
    if (EEPROM.read(i) != 0) {
      eraseFlag = true;
      break;
    }
  }
      
   if (eraseFlag) {
     for (int i=0; i< 640; i++){
       EEPROM.write(i, (byte) 0);                      // Reset EEPROM addresses to zero
     }
     EEPROM.put(0, (short) 15);                         // Reset array Pointer to start address (15)
     EEPROM.commit();
   }

 //-------------------------------------------------------------------------------

   

  EEPROM.get(0, arrayPointer);
  for (int i=15; i<319; i++) {
    EEPROM.get(i*2, pm25Array[i]);
  }
  
  tft.begin(); 

  tft.setRotation(1); 
  tft.setTextWrap(true);

  tft.fillScreen(ILI9341_BLACK);
  tft.setTextSize(1);
 

  sleepSeconds = (SAMPLE_INTERVAL * 60) - SAMPLE_SECS;          // Calculate sleep time in seconds.

  WiFi.begin(ssid, password);                                   // Connect WiFi to server running on PC to
                                                                // plot PM10 and PM2.5 values.
 
  int wifi_timeout = 0;
  tft.setCursor(0, 10);
  tft.print("Connecting to WiFi...");
  while (WiFi.status() != WL_CONNECTED)  {
    delay(10);
    tft.setCursor(0, 20);
    tft.fillRect(0, 20, 100, 10, ILI9341_BLACK);
    tft.print(wifi_timeout);
    wifi_timeout++;
    if(wifi_timeout > 1000) {
      tft.setCursor(0, 35);
      tft.println("Failed to connect to Wifi");
      break;
    }
  }

  if (WiFi.status() == WL_CONNECTED) {
    tft.setCursor(0, 20);
    tft.print("Connected to ");
    tft.println(WiFi.SSID());
    tft.print("IP address: ");
    tft.println(WiFi.localIP());
    delay(5000);
  }
 
  tft.fillScreen(ILI9341_BLACK);
 
  tft.setCursor(15, tft.height() -20);
 
  tft.setTextSize(1);                                         // Print static labels and headers on display.
  tft.setCursor(150, 10);
  tft.println("PM 2.5");
  tft.setCursor(150, 17);
  tft.print("ug/m3");

  tft.setCursor(5, 10);
  tft.print("PM 10"); 
  tft.setCursor(5, 17);
  tft.print("ug/m3"); 

  tft.fillRect(312, 10, 6, 10, PURPLE);                        // Print a colour key for the Defra pollutant bands.
  tft.fillRect(312, 20, 6, 10, DARK_RED);
  tft.fillRect(312, 30, 6, 10, MID_RED);
  tft.fillRect(312, 40, 6, 10, LIGHT_RED);
  tft.fillRect(312, 50, 6, 10, ORANGE);
  tft.fillRect(312, 60, 6, 10, MID_YELLOW);
  tft.fillRect(312, 70, 6, 10, LIGHT_YELLOW);
  tft.fillRect(312, 80, 6, 10, DARK_GREEN);
  tft.fillRect(312, 90, 6, 10, MID_GREEN);
  tft.fillRect(312, 100, 6, 10, LIGHT_GREEN);
    
  tft.drawFastVLine(13, 120, 108, ILI9341_BLUE);               // Draw histogram vertical axis
  tft.setTextColor(ILI9341_BLUE);
 
  tft.setCursor(0, 120);
  tft.print(" ^");
  tft.setCursor(0, 130);
  tft.print("50"); 
  tft.setCursor(0, 170);
  tft.print("10"); 
  tft.setCursor(0, 200);
  tft.print(" 1");

  tft.drawFastHLine(12, tft.height() - 14, tft.width()-1, ILI9341_BLUE);     // Draw histogram horizontal axis

  for (int x = 319; x > 15; x-=12) {
     tft.drawFastVLine(x, 227, 3, ILI9341_BLUE);                             // Draw 1-hour ticks on horizontal axis.
  }
  tft.setTextColor(ILI9341_BLUE);
  tft.setCursor(105, 232); 
  tft.print("Air Quality Monitor");

   int rssi = WiFi.RSSI();

   tft.setCursor(0,  232); 
   tft.fillRect(0, 232, 50, 8, ILI9341_BLACK);
   tft.setTextSize(1);

   if (WiFi.status() == WL_CONNECTED) {
     tft.setTextColor(ILI9341_BLUE);
     tft.print("RSSI " + String(rssi) + " dB");
   } else {
     tft.setTextColor(ILI9341_RED);
     tft.print("No WiFi");    
   }
   
  tft.setTextColor(ILI9341_GREEN);
 
  tft.setTextSize(3);
  
  Serial.begin(9600);
  sdsSensor.begin(SDS_TX, SDS_RX);    // Begin sensor and define Tx and Rx pins.

  plotHistogram();
                                    
}



void loop() {

  digitalWrite(SDS011_PWR, HIGH);                         // Turn on SDS011 Sensor power


  tft.setTextSize(1);                                     //
  tft.setCursor(248, 232);   
  tft.setTextColor(ILI9341_GREEN, ILI9341_BLACK);
  tft.print("SAMPLING ");  

  for (int i = SAMPLE_SECS; i>=0; i--) {                // Run fan for 30 seconds to ensure new air
    
    tft.setCursor(300,  232);                         
     if (i < 10) {
       tft.print("0");
     }
     tft.print(i);

     if (digitalRead(SAVE_DATA) == LOW) {
        saveData();
     }   
          
     delay(1000);
  }

  int raw = 0;                                             // Get supply voltage. Useful when battery operated.
  for (byte i=0;i<10;i++) {
    raw += analogRead(A0);
    delay(10);
  }
  raw = raw / 10;
  float volts = (raw / 1023.0) * Vmax;

  
  tft.fillRect(248, 232, 70, 10, ILI9341_BLACK);
 
  tft.fillRect(85, 105, 65, 8, ILI9341_BLACK);
    
	error = sdsSensor.read(&p25,&p10);                   // Read PM2.5 and PM10 values from sensor.

	if (! error) {
   Serial.print("P2.5: ");
   Serial.println(p25);
	 Serial.print("P10:  ");
   Serial.println(p10);
    

   int x = getTextData25(p25);                                          // Function retuns (width of text)/2 so we can
                                                                        // centre-justify it on the display. It also sets
   tft.setTextColor(colour);                                            // the text colour appropriate to the PM2.5 value as
                                                                        // defined by the UK Defra documentation.
                                                                        
   tft.fillRect(305, 10, 5, 105, ILI9341_BLACK);                        // Clear old triangle
   tft.fillTriangle(305, yPos, 308, yPos+5, 305, yPos+10, colour);      // Plot new position of triangle on colour scale
   
   tft.fillRect(100, 40, 110, 8, ILI9341_BLACK);                        // Clear display areas where new text will                
                                                                        // be drawn. (Graphical fonts don't overwrite
   tft.fillRect(0, 36, 285, 77, ILI9341_BLACK);                         // previous text.
   
   tft.setCursor(165 - x, 40);                                          // Set cursor to centre of display area.
   tft.print(quality);

   tft.setTextSize(2);
   tft.setFont(&FreeSansBold18pt7b);                                    // Change to new font.

   String sp25 = String(p25);                                           // Convert PM2.5 value to text because the
                                                                        // 'getTextBounds' function needs text.
   int16_t x1, y1;
   uint16_t w, h;
   tft.getTextBounds(sp25, 0,0, &x1, &y1, &w, &h);                      // We mainly want the width of the text that
                                                                        // we're about to print so we can centre-justify it.   
   tft.setCursor(183-(w/2), 110);
   tft.print(p25, 1);
   
   tft.setFont();                                                       // Revert to standard font.
   tft.setTextSize(1);
   
   tft.setTextColor(colour, ILI9341_BLACK);                             // PM10 data is less-used so just print it
   tft.fillRect(0, 30, 100, 10, ILI9341_BLACK);                         // in the top left corner of the display.
   tft.setCursor(0, 40);
   tft.print(quality);
   tft.setTextSize(2);
   tft.fillRect(0, 55, 60, 20, ILI9341_BLACK);
 
   tft.setCursor(2, 55);
   tft.print(p10, 1);

   tft.setTextSize(1); 
   tft.setTextColor(ILI9341_GREEN);
   tft.fillRect(80, 10, 50, 10, ILI9341_BLACK);
   tft.setCursor(80, 10);
   tft.print(volts);
   tft.print("v");

   tft.fillRect(245, 10, 30, 10, ILI9341_BLACK);


// ====== plot histogram (bar graph) ==============

   if (arrayPointer >= 319) {                              // If array has been filled, move all values down one.
     for (int i = 15; i <= 319; i++) {
       pm25Array[i] = pm25Array[i+1];
     }
   }
 
   pm25Array[arrayPointer] = (short) (p25 * 10);           // Multiply float value by 10 to make short integer.

   plotHistogram();
   
   if (arrayPointer < 319) arrayPointer++;                 // Increment the pointer to store the next value.



   delay(100);

 
 //======= end plot ====================


   int rssi = WiFi.RSSI();                                   // Get the WiFi signal strength and print on the display.

   tft.setCursor(0,  232); 
   tft.fillRect(0, 232, 50, 8, ILI9341_BLACK);
   tft.setTextSize(1);
   
   if (WiFi.status() == WL_CONNECTED) {
     tft.setTextColor(ILI9341_BLUE);
     tft.print(" RSSI " + String(rssi) + " dB");
   } else {
     tft.setTextColor(ILI9341_RED);
     tft.print(" No WiFi");    
   }
 

   if (WiFi.status() == WL_CONNECTED)  { 
     HTTPClient http;
 
    // Specify request destination, including your GET variables 
     
     String http_request = "";

     http_request = "http://" + serverIP  + "/apage?";      // Build the text string for the HTTP GET request to the PC server.
     http_request += "id=" + deviceId;
     http_request += "&leftaxis=" + sp25;
     http_request += "&rightaxis=" + String(p10);
     http_request += "&rssi=" + String(rssi);
     http_request += "&volts=" + String(volts);
    
     Serial.println("Making HTTP request...");
     Serial.println(http_request);

     http.begin(http_request);  
     
    // Send the request
     int httpCode = http.GET();

    // Check the returning HTTP code
     if (httpCode > 0) {
      // Get a response back from the server
       String payload = http.getString();
      // Print the response
       Serial.println("HTTP Response: ");
       Serial.println(payload);
     }

    // Close the HTTP connection
      http.end(); 

    } 
	}
 
	digitalWrite(SDS011_PWR, LOW);                        // Turn off SDS011 Power.

  delay(1000);
  tft.setTextSize(1);

  tft.setCursor(248, 232); 
  tft.setTextColor(ILI9341_BLUE, ILI9341_BLACK);
  tft.print("SLEEP   ");  

  int secs;
  int mins; 

 for (int i = sleepSeconds; i>0; i--) {                 // Sleep for the sample interval (less the 30 seconds warmup time)
     tft.setCursor(285,  232);                          //
     secs = i;
     tft.print(secs / 60);                              // Print minutes remaining.
     tft.print(":");
     
     if (secs % 60 < 10) {
       tft.print("0");
     }
     tft.print(secs % 60);                              // Print seconds remaining.
     tft.print(" ");

     if (digitalRead(SAVE_DATA) == LOW) {               // Manually save histogram data to EEPROM - don't wait for the
        saveData();                                     // auto-save after one hour to expire.
     }   

     delay(1000);
  }

  loopCount++;
  if (loopCount >= SAVE_COUNTER) {                      // Each loop takes 5 minutes. 12 loops = 5 * 12 = 60 minutes
    loopCount = 0;
    saveData();
  }

  Serial.print("Heap size at end of loop ");
  Serial.println(system_get_free_heap_size() );         // Free  memory check!

  
}

Credits

Mirko Pavleski

Mirko Pavleski

115 projects • 1161 followers

Comments