Air Quality Monitor
Hello there,
Today I am going to be walking you through how I built an Air Quality Monitoring station.
I am planing to use this tool to check air quality in areas around my house, as well as test some projects I have made in the past to see how effective they are.
The goal of this project is to make a small, portable station that can easily be moved around my house to check and monitor air quality.
I feel like links can often get barried in the text so I'll add them up here too.
So, I'll cut the rambling and get into it.
Supplies
The material list for this project for this is pretty long, and not super cheap unfortunately. But if you have only a few of the sensors or only care about some data you should be able to leave some out pretty easily, the one I would cut if I had to chose would be the ENS160 as it is the single most expensive part of this whole project.
Also, if you have hardware other than what I used go ahead and use that. Most of the fastener lengths are not critical, I do list 4 screws for each part but I, when testing, only used two for each and it was fine.
Materials
- ESP32
- Grove Dust sensor
- SD Card Module for Arduino
- 64x128 OLED
- ENS160 from Sparkfun
- AHT20 from Adafruit
- SD card
- Wire
- Solder
- proto board
- 30mm fan
- 5v Power brick
- Micro-USB cord
- M3 heatset inserts
- M2x12 Socket head cap screws (8x)
- M3x12 Socket head cap screws (4x)
- M3x18 Socket head cap screws (4x)
- M4x10 Socket head cap screw (1x)
- M2 Nuts (8x)
- M3 Nuts (4x)
- M4 Nut (1x) -- A kit like this should have most of the nuts and bolts you need
- Self tapping screws (12x)
Tools
- Soldering Iron
- Wire cutters
- Pliers
- Wire strippers
- Computer with Arduino IDE
- 3D printer - for the case
- Screw driver set
- I'm sure there are more I forgot about
Print the Case
First off lets print the case, the total print time for me was about 7 hours in the 4 parts.
They are over on printables. The only special note is for the two discs print them with 15% hexagon infill and 0 top and bottom layers. These will create little grates that are easy to print and install.
I printed all the parts out of ASA. If you only plan to use this around your house you should be able to get away with PLA too.
These parts should be easy to orient, print them on the big flat sides. I did add supports on the base but I printed the first version without and it worked fine.
For the case you will need to install some heat set inserts in each corner.
Assemble the Circuit
Before I dive too deep into this. I would recommend making sure all of this works on a bread board first. As most of my parts came with pin headers already attached it was simple enough to do. To remove the headers after testing I just use some side cutters to clip the plastic between each pin then desolder each pin one at a time. Now on to assembly!
The circuit for this is several very disparate parts. The main complication I ran into is that the ESP only has a few ground pins and 1 5v pin. To get around that I used a small piece of protoboard that had copper traces to add multiple options for these.
I also soldered only on one side of the PCB, for each so that all the boards could easily be mounted. On the OLED all the connections should be on the back and everything else they should all be on the front of the board. Take a look at some of the pictures for a better idea of this. I added a small amount of solder on to the pin then pressed the wire in to make the connection.
The OLED, ENS160, and AHT20 are all able to be connected with just four wires, 5v, GND, and 2 data wires. which is easy enough to jump wires between them all in a row. I did use a small Qwiik connector for the AHT->ENS connection wiring to the pins will work just as well.
The Dust sensor is simple, two power wires, and a single data pin. I cut the 3 pin off the provided connector and wired that in so that I could still use the larger end on the sensor.
I decided to use a small 30mm fan to help move air through the case to help get a better reading. It is just wired directly the 5v pin and the GND pin.
And, I've saved the hardest for last, the SD card module. This one uses 6 wires, 2 power, and 4 data, check the diagram for where they go, but just pay attention and I think you can do it.
Install Everything in the Case
The case is a bit tight but I wanted to keep everything compact.
First press the grates into the openings on either end, they are tight so they shouldn't need any glue, but feel free to add a dab of super glue if needed.
The SD card module is mounted on the top with M2 crews and nuts on the inside.
The ESP is mounted on the bottom, again with M2 screws.
The dust sensor is mounted using a single M4 crew and nut.
The OLED, ENS, and AHT are all mounted to the front cover with the self tapping screws.
Finally, the fan is mounted on the end using the longer crews, again, with the nuts on the inside.
To close everything up, use the remaining M3 screws to attach the front.
And boom, an air quality monitor is born.
Program It
Programing the ESP from the Arduino IDE requires some setup, I've linked another tutorial for that here.
Simply download the attached file or copy the code from below and put it into the IDE, connect the ESP and upload the program.
Do make sure to change the values in the secrects.h file to match your wifi network. If you plan to travel with this a lot it may be easiest to have it connect to a phone hot spot or something. It only connects to the internet on initial boot, so if you set it up, link it to the internet you should be able to disconnect the internet connection without issue.
I also am adding this code and the models to a github repo, if this code and that code look different use the one from there, as it will be the most up to date.
(If this is a significant amount of time past early summer of 2024 check out the GitHub repo for up to date code)
Downloads
Code Walk Through
Not going to lie to you, I think this is the most complex code I have ever written for an Arduino project...
I tried to break everything into functions to keep it easy to read, and most importantly, easy to trouble shoot. I’ll do my best to explain what is going on but some of this just works for some reason and I am not super sure why.
I tried to be good about comments in the code, anything with a leading // So some of the notes for the walk through are in the code.
All this stuff at the top is just adding libraries to the project so we can do more stuff later. Don’t worry about it too much but they need to be included for our sensor and outputs to work. There are also some global variable definitions mixed in. Global variables just mean we can use them any where in the code, as apposed to a local variable which could only be used with in a function. We’ll see a few of those later.
//Stuff needed for the SD card
#include "SPI.h"
#include "SD.h"
#include "FS.h"
#include "Wire.h"
//Stuff needed for the NTP sever (getting the time from the internet)
#include "WiFi.h"
#include "time.h"
//Stuff for the screen
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128 // OLED display width, in pixels
#define SCREEN_HEIGHT 64 // OLED display height, in pixels
#define OLED_RESET -1
It’s generally a bad idea to leave any passwords just in the code. So I have added a layer of obfuscation by putting them in a different file called “arduino_secrets.h”. Also not secure, but it keeps me from adding them directly to the repo.
//Password and stuff
#include "arduino_secrets.h"
//SparkFun AQI Sensor
#include "SparkFun_ENS160.h"
//adafruit AHT20
#include <Adafruit_AHTX0.h>
Adafruit_AHTX0 aht;
//creat global variables for the temperature and humidity
float temperature = 0;
float humidityA = 0;
//Stuff needed to get the time
const char* ssid = SECRET_SSID;
const char* password = SECRET_PSWD;
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = -21600;
const int daylightOffset_sec = 3600;
//global variables beacuase I cannot be assed to pass these back and forth without screwing it up
String fileName = "";
String timeString = "";
File myFile;
const int CS = 5;
Now we are on to setting stuff up, first is the screen, then the dust sensor and the stuff to calculate the average dust reading. After that is the ENS setup
//declare the display
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
//Stuff to make the AQI sensor work
int AQIpin = 34;
unsigned long duration;
unsigned long starttime;
unsigned long sampletime_ms = 30000;//sampe 30s ;
unsigned long lowpulseoccupancy = 0;
float ratio = 0;
float concentration = 0;
float runningTotal = 0;
int numMeasurments = 1;
int goodValues = 0;
float avg = 0;
//ENS stuff
SparkFun_ENS160 myENS;
int ensStatus;
int ppb = 0;
int ppm = 0;
int aqi = 0;
int flags = 0;
With all the boiler plate out of the way we can start doing stuff. Like actually setting stuff up in the setup function. The setup for each part is its own function because this got way too long to be functional. If you are unfamiliar with functions they are basically a sing post for the code to find another piece of the code and do that, then come back. I should note, Wire and Serial are both provided by either, the Arduino or a library we added above. The other functions are all things I wrote or copied for this project.
void setup() {
// put your setup code here, to run once:
Wire.begin();
Serial.begin(115200); //Create a serial output for debugging
Serial.println();
Serial.println("Begin");
wifiConnection();
setupSD(); // MUST BE FORMATED FAT NOT FAT32
pinMode(AQIpin, INPUT); // setup the AQI sensor pin
aht.begin();
ensSetup();
displaySetup();
}
Now on to the loop, this will keep running until the end of time if the ESP has power and the SD card has space.
It will do a few simple operations that look complex. First it will get some readings from the dust sensor, then it will check if enough time has passed to take another reading.
The formulas it uses are from the page on the sensor. I also increment the number of measurements. Not sure if I actually use that...
A fun quirk of the dust sensor, but sometimes it will just put out a reading that is way too low, like 0.42 or something, and it’s always consistent. I know this is not an accurate reading so the code for this takes that into account and if the value is too low, it throws it out. This will keep from throwing off the average with bad data.
Following that, it will take readings from the other sensors and add all that data to the file and output it to the serial monitor.
Then finally, update the screen.
void loop() {
duration = pulseIn(AQIpin, LOW);
lowpulseoccupancy = lowpulseoccupancy+duration;
if ((millis()-starttime) > sampletime_ms)//if the sampel time == 30s
{
ratio = lowpulseoccupancy/(sampletime_ms*10.0); // Integer percentage 0=>100
concentration = 1.1*pow(ratio,3)-3.8*pow(ratio,2)+520*ratio+0.62; // using spec sheet curve
numMeasurments += 1;
if(concentration > 1){
goodValues += 1;
runningTotal += concentration;
avg = runningTotal / goodValues;
} else {
concentration = 0.0;
}
lowpulseoccupancy = 0;
starttime = millis();
Serial.print("Concentration: ");
Serial.println(concentration); // this seems block for 30 seconds so no delay is needed
printLocalTime(); //update the time to be current
appendFile(SD, fileName.c_str(), timeString.c_str()); //Add the time to the file as a string
appendFile(SD, fileName.c_str(), ",");
String TempCON = String(concentration); //Convert the reading from a float to a string
appendFile(SD, fileName.c_str(), TempCON.c_str());
appendFile(SD, fileName.c_str(), ",");
//Print AQI Stuff
Serial.print("Air Quality Index (1-5) : ");
aqi = myENS.getAQI();
Serial.println(aqi);
String TempAQI = String(aqi); //Convert the reading from a float to a string
appendFile(SD, fileName.c_str(), TempAQI.c_str()); //Add the aqi to the file as a string
appendFile(SD, fileName.c_str(), ",");
Serial.print("Total Volatile Organic Compounds: ");
ppb = myENS.getTVOC();
Serial.print(ppb);
Serial.println("ppb");
String TempPPB = String(ppb); //Convert the reading from a float to a string
appendFile(SD, fileName.c_str(), TempPPB.c_str()); //Add the TVOC to the file as a string
appendFile(SD, fileName.c_str(), ",");
Serial.print("CO2 concentration: ");
ppm = myENS.getECO2();
Serial.print(ppm);
Serial.println("ppm");
String TempPPM = String(ppm); //Convert the reading from a float to a string
appendFile(SD, fileName.c_str(), TempPPM.c_str()); //Add the CO2 to the file as a string
appendFile(SD, fileName.c_str(), ",");
Serial.print("Gas Sensor Status Flag (0 - Standard, 1 - Warm up, 2 - Initial Start Up): ");
flags = myENS.getFlags();
Serial.println(flags);
sensors_event_t humidity, temp;
aht.getEvent(&humidity, &temp);
Serial.print("Temperature: "); Serial.print(temp.temperature); Serial.println(" degrees C");
Serial.print("Humidity: "); Serial.print(humidity.relative_humidity); Serial.println("% rH");
String TempTemp = String(temp.temperature); //Convert the reading from a float to a string
appendFile(SD, fileName.c_str(), TempTemp.c_str()); //Add the CO2 to the file as a string
appendFile(SD, fileName.c_str(), ",");
String TempRH = String(humidity.relative_humidity); //Convert the reading from a float to a string
appendFile(SD, fileName.c_str(), TempRH.c_str()); //Add the CO2 to the file as a string
appendFile(SD, fileName.c_str(), ",");
appendFile(SD, fileName.c_str(), "\n");
displayUpdate(); //Update the OLED display
Serial.println();
}
}
The rest of this is all the functions I use above. If you see something like functionName(); that is a function that is a call to something down here. If there is something in the () its data that the function will use.
This first one, connects to the WiFi, grab the time from the internet then disconnect. If you need to make this more portable, you could have it link to your phone hotspot for just a second to get the time, then it would be good in that location until you power cycle it.
//Connect to the wifi and get the time, pass back to the setup function
void wifiConnection(){
//connect to WiFi
Serial.printf("Connecting to %s ", ssid);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println(" CONNECTED");
//init and get the time
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
printLocalTime();
//disconnect WiFi as it's no longer needed
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
}
This function sets up the connection and communication to the SD card. It will open the card and make a new file with the name being the current date and time. It will also add a header line to that file so the file is somewhat more meaningful.
If you don't have the card setup right this function will catch and the code will stop until the issue is fixed and the unit is power cycled.
//Setup writing to the SD card, create the file name and add a header to the file
void setupSD(){
Serial.print("Initializing SD card...");
if (!SD.begin(CS)) {
Serial.println("initialization failed!");
while (1);
}
Serial.println("initialization done.");
//Create the file name with the current time
fileName = "/" + String(timeString) + ".txt";
Serial.print("File Name: ");
Serial.println(fileName);
myFile = SD.open("fileName.txt", FILE_WRITE);
myFile.close();
//Make sure the card is there
uint8_t cardType = SD.cardType();
if(cardType == CARD_NONE){
Serial.println("no Card");
}
appendFile(SD, fileName.c_str(), "Time, Concentration, AQI, TVOC, CO2, *C, %RH\n");
}
This function sets up the display and prints out a ready message on the screen. Adafruit has a really good tutorial on how all of this works, it is also much more detailed than just printing out text.
//setup the display and print out a ready message
void displaySetup(){
display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
display.clearDisplay();
display.setTextSize(2);
display.setTextColor(WHITE);
display.clearDisplay();
display.setCursor(0,0);
display.println("Ready!");
display.display();
Serial.println("Screen setup done.");
}
This function sets up the ENS sensor and also passed the temp and humidity to the sensor for correction. Not exactly sure what that does but the Sparkfun hookup guide says it can help with accuracy so why not? Check out their tutorial for even more info on this sensor.
void ensSetup(){
if( !myENS.begin() )
{
Serial.println("Could not communicate with the ENS160, check wiring.");
while(1);
}
if( myENS.setOperatingMode(SFE_ENS160_RESET) )
Serial.println("Ready.");
delay(100);
myENS.setOperatingMode(SFE_ENS160_STANDARD);
ensStatus = myENS.getFlags();
Serial.print("Gas Sensor Status Flag (0 - Standard, 1 - Warm up, 2 - Initial Start Up): ");
Serial.println(ensStatus);
sensors_event_t humidity, temp;
aht.getEvent(&humidity, &temp);
temperature = temp.temperature;
humidityA = humidity.relative_humidity;
// Give values to Air Quality Sensor.
myENS.setTempCompensationCelsius(temperature);
myENS.setRHCompensationFloat(humidityA);
}
printLocalTime takes in the time and prints the time in a specific format. This will update the global time variable as well so we can add that to the file.
//get the current time and put it onto the global time string
void printLocalTime(){
struct tm timeinfo;
if(!getLocalTime(&timeinfo)){
Serial.println("Failed to obtain time");
return;
}
Serial.println(&timeinfo, "%A %B %d %Y %H.%M.%S");
char time[32];
strftime(time,32, "%B%d%Y%H.%M.%S", &timeinfo);
timeString = time;
//Serial.println(time);
return;
}
This function will update the display with the new data. The screen output is always the same so making a function to update the screen was fairly simple to write.
//update the OLED display
void displayUpdate(){
display.setTextSize(2);
display.setTextColor(WHITE);
display.clearDisplay();
display.setCursor(0,0);
display.print("2.5:");
display.println(concentration);
display.print("Avg:");
display.println(avg);
display.print("VOC:");
display.println(ppb);
display.print("CO2:");
display.println(ppm);
display.display();
}
This code will take in the file name, and the message, open the file, write the message, and close the file. It the wire it successfully it will output a success message, if it fails it will output a failed message. I can’t take credit for this, I copied this from this tutorial.
void appendFile(fs::FS &fs, const char * path, const char * message){
Serial.printf("Appending to file: %s\n", path);
File file = fs.open(path, FILE_APPEND);
if(!file){
Serial.println("Failed to open file for appending");
return;
}
if(file.print(message)){
Serial.println("Message appended");
} else {
Serial.println("Append failed");
}
file.close();
}
That's all the code! Clocking in at a respectable 299 lines
Test It Out!
Now that it all works and you totally understand every single line of code, thanks to my incredible explanation above, you are ready to give it a test.
I decided to stick mine into my printer enclosure and run a few prints some with ASA and some with PLA I also have a Bento Box air filter in there so I ran one of each with and without the filter. Lets look at the data in the next step.
Simple Data Analysis
Now that you have collected a bit of data with the unit, copy the file onto your computer. Right click on it and choose, rename, and change the .txt ending to a .csv file. This will make it so Excel will automatically open it or it can be imported into google sheets easily. It should have a semi decent header row and then all the data below it. You will need to use the ‘text to columns’ feature to break the data apart if you care too.
To actually use the data, I am just selecting the time column and the dust column to make a little graph. I think this gives a good idea over time.
This large type of data set really should be analyzed using something other than a spreadsheet but for me this gets the point across. Maybe I will look at writing some python to do some more robust analysis.
The graphs attached to this step are the same ones I go over in the video so check that out for what is happening, but, briefly I ran the same print, same gcode, twice, once with the filter on and once with it off. The data, attached in a csv, was then lined up so that I could compare the two prints. I only really looked at the concentration and the TVOC and skipped over the eCO2, temp, and relative humidity. (to convert from a CSV to a spreadsheet you can use the 'text to columns' to split it at the , if it doesn't open correctly).
Downloads
Conclusion
That's it! I am really happy with how this turned out it works pretty well, collects the data I was hopping for. I do plan to keep making some small tweaks to the the models and make some updates to hopefully stream line the code. So check GitHub for the most up to date stuff.
If you would like to support my projects consider tossing a few bucks my way over here -> https://ko-fi.com/tinyboatproductions
Thank you for reading all the way to the end!