by Matthew Baas
Making a functional air quality sensor with a raspberry pi and a few sensors, with a companion app.
I looked around online and in person for a reasonably functional home air quality sensor. Unfortunately, all the products or solutions I found had at least one of the following big problems:
So, still wanting to obtain an air quality sensor that I could actually use and not sacrifice on the above, I had to design my own system and software stack. In the following sections I detail the steps taken to design and implement the system, as well as the final result. Let us get started.
We want an air quality system that:
With these considerations in mind, the setup I came up with is given in the system diagram below:
This setup covers a swath of the basic air quality measurements and does not require any additional header boards or extenders. If, for example, I only used sensors that communicate via UART, I would not have enough UART pins directly on the Pi Zero and would need to make some extender or multiplex arrangement.
The specific sensors chosen for each block above was a function of (a) what is readily available to ship to my location, (b) documentation and datasheet availability, and (c) cost. With these criteria in mind, the full parts list is as follows:
The software stack is split into 2 parts. The first part is the software on the raspberry pi. It is chosen based on (a) what is simplest and quickest to implement, (b) what pre-existing libraries exist of each sensor, and (c) what programming languages I am proficient in.
In light of this, the software stack is dead simple: a python script which runs when the raspberry pi boots. Each of the sensors has a python package available online which makes connection to devices very simple. Then with some networking code we connect to the server regularly to send new sensor readings.
The Python code on the raspberry pi will interact with a node backend server on Oxide, which will also act as the database and distribution center for all the desktop and mobile applications. It is currently $10/month for everything included (database, iOS/desktop/android apps, node server).
Since this post is in part meant to help anyone trying to make such a system themselves (as opposed to purely documenting the system I built), I will not give exact pin connections since there is a good chance that the specific sensor or raspberry pi models you have will have different pinouts than the ones I have used here.
First, most Raspberry Pi models have the same GPIO pin layout, namely:
In the figure above, I have highlighted which pins will be used by each sensor, excluding the power pins. The Pi itself is connected to the external 5V power source via its micro-USB connector (or whatever other way you would like to power it).
Now we delve into the connections for each sensor. Typically for each sensor, we will need to connect its power/ground pins and its data communication pins.
Each sensor needs to be connected to a ground pin on the Pi, and a power pin. Each sensor is slightly different – some are powered from a 5V source, while other uses a 3.3V source. So – carefully checking the datasheet for each sensor – connect the power pins to the appropriate ones on the Pi (5V or 3.3V).
Using the previous image as reference, the central requirement is that the pins used to transmit data between the Pi and each sensor must align with the communication protocol each sensor uses. Concretely:
The precise pinout on each sensor which correspond to the each of the pins discussed above might change – so just consult each datasheet and make sure match the pinout for your chip to the right pin on the Pi.
That is actually all that is required for the core functionality! Not too bad. Here is what it looks like once it’s all connected:
If you have a 3D printer, you can model a nice case for the entire contraption in your modelling software of choice (I used Blender because it is free, has no weird DRM or shenanigans, and is available on steam).
If the physical layout of your sensors or Pi is different to the ones I have used here, you will unfortunately need to make your own casing model. The process I followed to make mine was fairly simple (as I have not much experience in 3D modelling):
Aside: A big thanks to the manufacturers and random people on the internet who provide 3D models for the sensors (or their casings). In the interest of saving time I used others’ models as much as I could, so bless those people.
Then, I exported the model as a STL file, sliced it with a slider configured for the 3D printer I had access to, and then printed it!
For reference, here is the blender file I used: link to blender case.
In this section I’ll go over the software setup on the Raspberry Pi device. It is all done in python, and I use as many device communication libraries as possible to minimize setup time.
There are many great resources online about how to set up a raspberry pi (such as this one), but the TL;DR:
pip3 install requests
pygpio, see instructions here
pip3 install pms5003
pip3 install Adafruit-BME280
circuitpython: install with instructions given here, but TL;DR:
pip3 install Adafruit-Blinka. You may need some additional setup for the right circuitpython stuffs if a new version breaks something.
The general plan to read the data from each sensor is as follows:
Let’s briefly go over the method to read data from each sensor, where I will leave the details to the library docs for each sensor (see docs for each of the package requirements above).
For the BME280, I directly use the circuit python code, and it is fairly simple and clean:
import time import pigpio import board import busio import digitalio import adafruit_bme280 ... pi = pigpio.pi() ... class BME280(): def __init__(self, pi, weighting=0.0, SPI=True): self.pi = pi # Setup SPI communication self.spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO) self.cs = digitalio.DigitalInOut(board.D0) self.bme280 = adafruit_bme280.Adafruit_BME280_SPI(self.spi, self.cs) # Setup recommended settings for indoor use self.bme280.iir_filter = adafruit_bme280.IIR_FILTER_DISABLE self.bme280.mode = adafruit_bme280.MODE_FORCE self.bme280.overscan_humidity = adafruit_bme280.OVERSCAN_X2 self.bme280.overscan_pressure = adafruit_bme280.OVERSCAN_X2 self.bme280.overscan_temperature = adafruit_bme280.OVERSCAN_X2 def get_sensor_readings(self): """ Gets the pressure in kPa, temperature in Celsius, and humidity in percent """ temp = self.bme280.temperature hum = self.bme280.relative_humidity pressure = self.bme280.pressure # in hPa altitude = self.bme280.altitude return temp, hum, pressure/10.0, altitude
Now we just call
get_sensor_readings to obtain the latest measurements – quite simple.
For this sensor I adapt this github gist with the following function to collate measurements:
def get_sensor_readings(self): st = time.time() while time.time() - st < 1.1: # wait at most 1.1 second for new reading if self.data_available(): self.read_logorithm_results() if self.CO2 > 2**13 or self.tVOC > 2**13: continue # make sure no bs errors return self.CO2, self.tVOC elif self.check_for_error(): self.print_error() time.sleep(0.02) # sleep for 20ms raise ValueError("Could not read values in time!")
I also set the
0x5B , which corresponds to the specific SPI address of the particular sensor – this can usually be found in the datasheet.
Again, I use the
pms5003 python library to make a simple class to read the data with a function:
import time from pms5003 import PMS5003 PMS5003_SET_PIN = 23 # GPIO pin 23 PMS5003_RESET_PIN = 24 # GPIO pin 24 class PMS5003Link(): def __init__(self, pin_enable=PMS5003_SET_PIN, pin_reset=PMS5003_RESET_PIN): self.pms5003 = PMS5003(device='/dev/ttyAMA0', baudrate=9600, pin_enable=pin_enable, pin_reset=pin_reset ) def get_readings(self): data = self.pms5003.read().data pm1_0 = data # PM1.0 ug/m3 (ultrafine particles): pm2_5 = data # PM2.5 ug/m3 (combustion particles, organic compounds, metals) pm10 = data # PM10 ug/m3 (dust, pollen, mould spores) conc_g0_3 = data # >0.3um in 0.1L air: conc_g0_5 = data # >0.5um in 0.1L air: conc_g1 = data # >1.0um in 0.1L air: conc_g2_5 = data # >2.5um in 0.1L air: conc_g5 = data # >5.0um in 0.1L air: conc_g10 = data # >10um in 0.1L air: return pm1_0, pm2_5, pm10, conc_g0_3, conc_g0_5, conc_g1, conc_g2_5, conc_g5, conc_g10
Finally, for the CO2 sensor, I combine this script to read a PWM signal level with the following functions to adapt it for the CO2 measurement:
def parse_co2_measurement(self): """ Parses sensor co2 measurement into a ppm float number""" f = self.frequency() pw = self.pulse_width() dc = self.duty_cycle() period_ms = 1000/f # convert microsecond pulsewidth --> ms pw_ms = int(pw + 0.5)/1000.0 co2_ppm = to_co2_reading(pw_ms, period_ms) return co2_ppm def to_co2_reading(high_period_ms, total_pwm_period_ms): co2_ppm = 2000*(high_period_ms - 2)/(total_pwm_period_ms - 4) return co2_ppm
Now each sensor has a function to obtain the readings.
So now in a central class we instantiate the
pi = pigpio.pi() object and each sensor class. Then, at a high level, it:
That’s it! It is fairly easy to see how this can be adapted to sending the data anywhere or using it for other display paths, like a local django/flask server or similar. But, for myself, I wanted some nice mobile applications so next I detail the Oxide setup.
This part of the code is exceedingly trivial. I used the
requests package and then simply indexed the last read sample in the main class, and sent a POST request to the url endpoint set up in Oxide (discussed later) with the payload containing the read samples and authorization header to authenticate my request.
Done! That is everything for the local side; all that is left is to code up the server endpoints/database, and front-facing app to show the sensor readings.
For the backend server and mobile/desktop applications I use Oxide. It is a browser IDE that also comes with database and mobile/desktop application support with minimal setup – which is necessary since I am not too experience with mobile app development. As mentioned before, you can use another backend setup if it works better in your situation.
So for this Oxide project, I make use of 3 main aspects of Oxide:
I go over each of these next, starting with the project setup.
revisionssince this is a small project)
Now that you are inside the Oxide editor, let’s proceed to the
Data model to setup the database.
For my simple application, I just want the database to store the last few days of sensor readings and only on my phone/desktop – feel free to deviate at any step if you have other cool ideas.
Now, in the
Data model GUI editor, you should already have a
Push Notification model.
To add a data model object for sensor readings, add the following lines to the
<model name="sample" label="Sample"> <field name="co_2_ppm" label="CO2ppm" type="number" /> <field name="eco_2_ppm" label="eCO2ppm" type="number" /> <field name="tvoc_ppb" label="TVOC_ppb" type="number" /> <field name="timestamp" label="timestamp" type="datetime" /> <field name="temp_c" label="temp_C" type="number" /> <field name="pressure_kpa" label="pressure_kPa" type="number" /> <field name="humidity_perc" label="humidity_perc" type="number" /> <field name="particles_0_3" label="particles_0_3" type="number" /> <field name="particles_0_5" label="particles_0_5" type="number" /> <field name="particles_1" label="particles_1" type="number" /> <field name="particles_2_5" label="particles_2_5" type="number" /> <field name="particles_5" label="particles_5" type="number" /> <field name="particles_10" label="particles_10" type="number" /> <field name="pm_1" label="pm_1" type="number" /> <field name="pm_2_5" label="pm_2_5" type="number" /> <field name="pm_10" label="pm_10" type="number" /> <belongs-to model="user" /> <display>Sample</display> </model>
This will add a
Sample data model object, where each sample is linked to a specific user, and each
Sample contains a value for all sensor readings at a particular time. I.e. it is one sample received from the Raspberry Pi.
Now that the database is sorted, we need to allow Oxide to receive samples from the Raspberry Pi.
To do this, we will use the
CloudCode feature of Oxide – which is essentially a kind of NodeJS server.
It offers a bunch of additional features as discussed in their docs, but we only need a single API endpoint for the raspberry pi to send the samples to.
To make such an endpoint, in the
CloudCode editor tab of Oxide:
const body = await request.json(); let sample = DB.sample.create(); sample.timestamp = new Date(); sample.co_2_ppm = body.CO2_ppm; sample.eco_2_ppm = body.eCO2_ppm; sample.tvoc_ppb = body.tVOC_ppb; sample.temp_c = body.temp_C; sample.humidity_perc = body.hum_perc; sample.pressure_kpa = body.pres_kPa; sample.pm_1 = body.pm1_0; sample.pm_2_5 = body.pm2_5; sample.pm_10 = body.pm10; sample.particles_0_3 = body.conc_g0_3; sample.particles_0_5 = body.conc_g0_5; sample.particles_1 = body.conc_g1; sample.particles_2_5 = body.conc_g2_5; sample.particles_5 = body.conc_g5; sample.particles_10 = body.conc_g10; await sample.save(); response.status(200);
deploy to testingbutton. Your endpoint will now be available at
All that’s left is now to make the frontend application to view the sensor readings.
For the frontend app, I use Chart.js for graphs, and pure HTML with typescript. I’ll spare you the spam of a page full of HTML and Typescript and just link the HTML file here and the typescript file here.
In short, the application contains a button to update the data and an iframe to display the graphs. This is because the prefab assets supported by Oxide at this time does not include graphs, so we must use an iframe with custom HTML to make them. The HTML file links with the typescript functions using the JourneyIFrame client as shown in the HTML file. To setup this frontend in the Oxide editor, the steps are again fairly trivial:
Assetstab, and add the HTML file as an html asset by right-clicking the
htmlfolder in assets and hitting Upload assets.
Viewstab in Oxide, hit
ctrl-shift-pand run the
Convert project to TypeScriptcommand.
Viewstab, replace the
main.tsfunction with the typescript file provided earlier.
viewtag of the
<var name="sample_table" type="query:sample" /> <button on-press="$:buttonPress()" label="Update" type="primary" /> <html src="html/<name of uploaded html file>.html" show-fullscreen-button="false" height="$:size_canvas()" />
And that’s it! Now hit the
deploy to testing button and it should be live. You can download the JourneyApps container from the app store on iOS/Android, or view it on the web by hitting
ctrl-shift-p and searching for
Test on web.
Here is what it looks like on desktop:
And on an iPhone:
Each graph shows a different sensor readings (or set thereof for grouped readings like PM1, PM2.5, PM10) for the last three days, and is updated whenever the Update button is hit or the app is opened. Pretty neat :).
So, after all this effort, we now have a small, portable device that can measure 14 different air quality related values, relay these to a server, and then display it for us on a desktop and mobile app.
And all of this without any additional hardware parts other than the base IC chips – which is often not the case. Often when combining several sensors one needs additional pull-up or pull-down resistors or other supporting circuity to make things work nicely (e.g. an op-amp is often necessary for audio or current measurement sensors). Whereas in this project, all we needed were the sensors themselves and some wires.
Similarly, the server setup was fairly trivial – no IDE installs or dealing with Apple or Google Inc to get a mobile app up an running, and just an overall very short time to get things working.
I hope you found this half-documentation-half-guide on making a home air quality sensor useful, should you ever think of making such a sensor yourself. I can highly recommend it over nearly all the air quality sensor hubs I have seen available for purchase online.
And, lastly, may the best of air quality be upon you.
Thank you for reading, and as per usual, if you have any comments or ideas please get in touch via the About page.tags: electronics - air quality - raspberry pi - python - typescript