2017 was a bad year for fires in California. The Tubbs Fire in Sonoma County in October destroyed whole neighborhoods and sent toxic smoke south through most of the San Francisco Bay Area. The Air Quality Index (AQI) for parts of that area went up past the unhealthy level (101–150) to the hazardous level (301–500) at certain points during the fire. Once word got out that N99 dust masks were needed to keep the harmful particles out of the lungs, they became a common sight.

The EPA maintains the AirNow website, which displays the AQI for the entire US. The weather app on my Pixel phone conveniently displays a summary of the local air quality from the EPA’s source. This was my goto source for information about the outdoor air quality once the fires started. However, I started to notice that the observed air quality often didn’t match that of the app. I realized that the data reported on the app was often delayed by an hour or more, and the local air quality could change much more quickly than was reported by the AirNow resource.

Pixel Weather App air quality

I started to look into how often the data was updated, and where the sensors that collected it were located. Unfortunately, I wasn’t able to find a lot of details. However, I did come across a link to PurpleAir.com while browsing a local news site. PurpleAir reports on the same air quality metrics as the EPA source, but uses sensors that were hosted by individuals. They have a network of over a thousand sensors across the planet, and the sensor density in the SF Bay Area is quite good, as can be seen from their map. Best of all, the data was reported in real-time. They had one hour averages, last twenty four hours average, particle counts, and so on. This made it easy to check the air quality reported in real-time from a sensor close by, letting us know when it was okay to go out, and when things had gotten bad in our area.

PurpleAir.com map

I considered obtaining one of these sensors for myself at the time, but as the Tubbs fire faded, I stopped checking the site as often. However, shortly after the Thomas fire in December, I decided to purchase a Purple Air PA-II sensor. The sensor was easy to setup. I connected it to my WiFi network, gave it a name, location, and some other metadata. It also allowed me to give the sensor a custom url (KEY3) to PUT sensor data. KEY4 allowed me to set a custom HTTP header.

Circonus allows you to post data to it as a JSON object using an HTTPTrap endpoint. I didn’t know what format the PA-II would use to send the data, but I thought this was a good guess. So I created an HTTPTrap, grabbed the data submission URL, and put it into the sensor configuration. About thirty seconds later, metrics started flowing into Circonus. PurpleAir shared a helpful document that described each of these data points.

HTTPTrap configuration

I wanted to create my own dashboard, but first I needed to understand the data. It turns out that AQI is calculated from the concentration of 2.5 micron and 10 micron particles in micrograms per cubic meter. I wasn’t able to find an equation that allows AQI calculation from these concentrations; it appears that AQI is linearly interpolated between different particulate concentration ranges.

In addition to PM2.5 and PM10, the PA-II provided a number of other particle measurements from its dual laser sensors; in particular, particles per deciliter for particles ranging from 0.3 to 10 microns. It seems that the small particles under 1 micron are exhaled, whereas the 1–10 micron particles are the ones that become lodged in the lungs. The PA-II sensor also provides other metrics such as humidity, barometric pressure, dewpoint, temperature, RSSI signal strength to the WiFi access point, and free heap memory. I put together a dashboard to track these metrics.

Temperature, Humidity, Air Pressure, Dewpoint

PM2.5 (2.5 micron) and PM10, micrograms per cubic meter. Log 20 scale. AQI levels added as horizontal lines.

PM1 micrograms per cubic meter, and 0.3/0.5/1.0/2.5 micron particles per deciliter

Free heap memory, WiFi signal strength, and 5.0/10.0 micron particles per deciliter

Now that I had a dashboard up and running, I could keep a good watch on local air quality, for my neighborhood specifically, in addition to some simple weather measurements. This was quite useful, but I wanted to get a handle on when the air quality started to go bad. So I created a rule to send an alert whenever the PM 2.5 count went over 12, from Good to Moderate.

Circonus rule

It took a bit of digging, but I was able to find the AQI breakpoints which correlates air quality index values to PM 2.5 µg/m3. The relation between AQI and particle concentration wasn’t linear across the categories, so I couldn’t apply a formula to calculate the conversion directly. I settled on adding threshold lines to the graphs for each different AQI category. However, I was able to easily set alerts for each threshold for the particulate count boundaries.

AQI breakpoints

If the air quality changed, I got a text message. I created several rules with varying levels of severity, so that I could get an idea of how fast the air quality was changing.

At this point I had a pretty good setup; if the air got bad, I got a text message. The air sensor itself was pretty sensitive to local air quality fluctuations; if I fired up the meat smoker, I’d get an alert. Overall the system was fairly stable, but I did run into some issues where data wasn’t sent to the HTTPTrap at certain times. As a former WiFi firmware engineer, I decided to use tcpdump to look at the traffic directly. To do this, I had to get a host between the sensor and the internet, so I shared my iMac internet connection over WiFi and connected the air sensor to it. The air sensor has a basic web interface that you can use to specify the WiFi connection, and also get a real time readout of the laser air sensors.

Once I had the sensor bridged through my iMac I was able to take a look at the network traffic. The sensor used HTTP GET requests to update the PurpleAir map, and as I had specified in the configuration interface, PUT requests to the Circonus HTTPTrap. Oddly enough, things worked just fine when requests were being routed through the iMac. I came to the conclusion that the Airport Extreme that the sensor was normally associated with might be the source of the failed PUT requests at the TCP level somehow. This is something I need to put some more energy into at some point, but these types of network level issues can be tricky to debug.

me@myhost ~ $ sudo tcpdump -AvvvXX -i bridge100 dst host 192.168.2.2 or src host 192.168.2.2 10:58:11.781392 IP (tos 0x0, ttl 128, id 3301, offset 0, flags [none], proto TCP (6), length 1498) 192.168.2.2.csdmbase > 199.69.201.35.bc.googleusercontent.com.http: Flags [P.], cksum 0xf46e (correct), seq 1:1459, ack 1, win 5840, length 1458: HTTP, length: 1458 PUT /module/httptrap/xxxxxxxx-adf9–40ec-bcc8–yyyyy3194/xx HTTP/1.1 Host: trap.noit.circonus.net X-PurpleAir: 1 Content-Type: application/json User-Agent: PurpleAir/2.50i Content-Length: 1231 Connection: close {“SensorId”:”5c:cf:7f:4b:f8:c4",”DateTime”:”2018/03/12T17:58:10z”,”Geo”:”AirMonitor_f8c4",”Mem”:26536,”Id”:156,”Adc”:0.03,”lat”:37.745113,”lon”:-122.421211,”accuracy”:152,”elevation”:67.58,”version”:”2.50i”,”uptime”:4160,”rssi”:-77,”hardwareversion”:”2.0",”hardwarediscovered”:”2.0+BME280+PMSX003A+PMSX003B”,”current_temp_f”:87,”current_humidity”:26,”current_dewpoint_f”:47.99,”pressure”:1016.27,”pm1_0_atm_b”:4.82,”pm2_5_atm_b”:7.25,”pm10_0_atm_b”:7.59,”pm1_0_cf_1_b”:4.82,”pm2_5_cf_1_b”:7.25,”pm10_0_cf_1_b”:7.59,”p_0_3_um_b”:1116.89,”p_0_5_um_b”:313.32,”p_1_0_um_b”:45.95,”p_2_5_um_b”:1.89,”p_5_0_um_b”:0.32,”p_10_0_um_b”:0.32,”pm1_0_atm”:3.98,”pm2_5_atm”:6.02,”pm10_0_atm”:6.30,”pm1_0_cf_1":3.98,”pm2_5_cf_1":6.02,”pm10_0_cf_1":6.30,”p_0_3_um”:983.05,”p_0_5_um”:280.57,”p_1_0_um”:37.09,”p_2_5_um”:2.23,”p_5_0_um”:0.52,”p_10_0_um”:0.25,”key1_responseCode”:”200",”key1_responseCode_date”:1520877431,”key1_count”:55387,”key2_responseCode”:”200",”key2_responseCode_date”:1520877441,”key2_count”:54417,”responseCode_b”:”200",”responseCode_date_b”:1520877411,”key1_responseCode_b”:”200",”key1_responseCode_date_b”:1520877461,”key1_count_b”:55450,”key2_responseCode_b”:”200",”key2_responseCode_date_b”:1520877471,”key2_count_b”:55679}[!http] 0x0000: ca2a 14f1 e064 5ccf 7f4b f8c4 0800 4500 .*…d\..K….E. 0x0010: 05da 0ce5 0000 8006 fbfe c0a8 0202 23c9 …………..#. 0x0020: 45c7 05bb 0050 0054 d1fa 9ab3 12ce 5018 E….P.T……P. 0x0030: 16d0 f46e 0000 5055 5420 2f6d 6f64 756c …n..PUT./modul 0x0040: 652f 6874 7470 7472 6170 2f31 6430 3131 e/httptrap/1d011 0x0050: 6339 332d 6164 6639 2d34 3065 632d 6263 xxx-xxx–40ec-bc 0x0060: 6338 2d35 3866 6634 3036 3733 3139 342f c8–xxxxxxxxxxx/ 0x0070: 6d79 7333 6372 3374 2048 5454 502f 312e xxxxxxxx.HTTP/1. 0x0080: 310d 0a48 6f73 743a 2074 7261 702e 6e6f 1..Host:.trap.no 0x0090: 6974 2e63 6972 636f 6e75 732e 6e65 740d it.circonus.net. 0x00a0: 0a58 2d50 7572 706c 6541 6972 3a20 310d .X-PurpleAir:.1. 0x00b0: 0a43 6f6e 7465 6e74 2d54 7970 653a 2061 .Content-Type:.a 0x00c0: 7070 6c69 6361 7469 6f6e 2f6a 736f 6e0d pplication/json. 0x00d0: 0a55 7365 722d 4167 656e 743a 2050 7572 .User-Agent:.Pur 0x00e0: 706c 6541 6972 2f32 2e35 3069 0d0a 436f pleAir/2.50i..Co 0x00f0: 6e74 656e 742d 4c65 6e67 7468 3a20 3132 ntent-Length:.12 0x0100: 3331 0d0a 436f 6e6e 6563 7469 6f6e 3a20 31..Connection:. 0x0110: 636c 6f73 650d 0a0d 0a7b 2253 656e 736f close….{“Senso 0x0120: 7249 6422 3a22 3563 3a63 663a 3766 3a34 rId”:”5c:cf:7f:4 0x0130: 623a 6638 3a63 3422 2c22 4461 7465 5469 b:f8:c4",”DateTi

Overall, I’m pleased with the result. There are a couple more things I want to try with the sensor, such as putting a second order derivative alert on the air pressure metric to tell when a low pressure region is moving in. The folks at PurpleAir.com were kind and helpful in responding to any questions I had. I’m looking forward to trying out some other sensors that I can plug into a monitoring system. Amazon has a C02 sensor, so that might be next on my list.