DIY indoor PM2.5 and weather station

Gain
8 min readFeb 5, 2023

--

TBH, you don’t really need this. You need a Clean Air Act and a strong enforcement.

It’s an air pollution season again in Bangkok and the PM2.5 values jump super high every single day when it’s cold (because it usually come with low ventilation). Based on my observation, the PM2.5 values are highly correlated with the minimum daily temperature and night time temperature.

Since I wrote an article on “PM2.5 andData science” back in 2019, one things that was clear to me at that time was that we didn’t have enough data. Fast forward 4 years after, we have plenty of air quality stations with times series data. Numerous studies have been published since then. But the air quality problem still exist even though we know the cause. It’s crispy clear to me that unless we have a clean air act and a strong enforcement, we are going to kill 22,000+ people in Thailand every year accroding to the Greenpeace study. I also want to urge the Thai government agencies and the Bangkok governer to STOP using an observation data to inform people. Instead, they should use PM2.5 forecast data to notify people and implement countermeasure policies in advance (1 week ahead).

Now, if you’re still want to make your own PM2.5 + weather station, you can follow my notes below. Just a heads up, it’s not well structured and doesn’t have a clear outline. This article is kinda in my draft box for a long time and I never finish it. Today is the day and I just gonna give you a bunch of links and codes that I use to make this weather station. Enjoy!

Example of PM2.5 measurement devices in the market

Why make your own when you can buy one?

  • Most PM2.5 monitor devices are pretty expensive
  • They don’t store time series data or even if they do you can’t really export the data for your own analysis.
  • They don’t have an online dashboard for easy access and sharing
  • They can’t send you a notification to your phone.
Example of customized LINE messages notification

Hardware:

  • Raspberry Pi Zero W — $15 (there’s a newer version as well)
  • 8GB or more micro SD card — $10
  • BME280 Humidity + Barometric Pressure + Temperature Sensor — $6
  • Nova SDS011 PM2.5 sensor — $25
  • (Optional) Raspberry Pi Zero Case — $7.5
  • (Optional) Male Header 2.54mm 2x20pin Strips for Raspberry Pi Zero — ($1-5) You can skip this, if you will solder the BME280 pin to Pi Zero directly.
  • Micro usb cable
  • Any 1A USB wall charger from your phone charger — $4

Tutorial:

The end product of my messy device. I use a buch of tape to stick them together.
Sometimes when I cook with oil and I didn’t open the window, the PM2.5 value jump pretty high. So don’t forget about ventilation when you cook!

Here’re the code I adapted from the tutorial:

  1. log_pm25_sample.py
  • Read PM2.5 and PM10 values from the sensor for 30 seconds
  • Averages each PM value across 30s
  • Save data to pm25.json
#!/usr/bin/python -u
# coding=utf-8
# "DATASHEET": http://cl.ly/ekot
# https://gist.github.com/kadamski/92653913a53baf9dd1a8
from __future__ import print_function
import serial, struct, sys, time, json, subprocess

DEBUG = 0
CMD_MODE = 2
CMD_QUERY_DATA = 4
CMD_DEVICE_ID = 5
CMD_SLEEP = 6
CMD_FIRMWARE = 7
CMD_WORKING_PERIOD = 8
MODE_ACTIVE = 0
MODE_QUERY = 1
PERIOD_CONTINUOUS = 0

JSON_FILE = '/home/pi/pm25.json'

MQTT_HOST = ''
MQTT_TOPIC = '/weather/particulatematter'

ser = serial.Serial()
ser.port = "/dev/ttyUSB0"
ser.baudrate = 9600

ser.open()
ser.flushInput()

byte, data = 0, ""

def dump(d, prefix=''):
print(prefix + ' '.join(x.encode('hex') for x in d))

def construct_command(cmd, data=[]):
assert len(data) <= 12
data += [0,]*(12-len(data))
checksum = (sum(data)+cmd-2)%256
ret = "\xaa\xb4" + chr(cmd)
ret += ''.join(chr(x) for x in data)
ret += "\xff\xff" + chr(checksum) + "\xab"

if DEBUG:
dump(ret, '> ')
return ret

def process_data(d):
r = struct.unpack('<HHxxBB', d[2:])
pm25 = r[0]/10.0
pm10 = r[1]/10.0
checksum = sum(ord(v) for v in d[2:8])%256
return [pm25, pm10]
#print("PM 2.5: {} μg/m^3 PM 10: {} μg/m^3 CRC={}".format(pm25, pm10, "OK" if (checksum==r[2] and r[3]==0xab) else "NOK"))

def process_version(d):
r = struct.unpack('<BBBHBB', d[3:])
checksum = sum(ord(v) for v in d[2:8])%256
print("Y: {}, M: {}, D: {}, ID: {}, CRC={}".format(r[0], r[1], r[2], hex(r[3]), "OK" if (checksum==r[4] and r[5]==0xab) else "NOK"))

def read_response():
byte = 0
while byte != "\xaa":
byte = ser.read(size=1)

d = ser.read(size=9)

if DEBUG:
dump(d, '< ')
return byte + d

def cmd_set_mode(mode=MODE_QUERY):
ser.write(construct_command(CMD_MODE, [0x1, mode]))
read_response()

def cmd_query_data():
ser.write(construct_command(CMD_QUERY_DATA))
d = read_response()
values = []
if d[1] == "\xc0":
values = process_data(d)
return values

def cmd_set_sleep(sleep):
mode = 0 if sleep else 1
ser.write(construct_command(CMD_SLEEP, [0x1, mode]))
read_response()

def cmd_set_working_period(period):
ser.write(construct_command(CMD_WORKING_PERIOD, [0x1, period]))
read_response()

def cmd_firmware_ver():
ser.write(construct_command(CMD_FIRMWARE))
d = read_response()
process_version(d)

def cmd_set_id(id):
id_h = (id>>8) % 256
id_l = id % 256
ser.write(construct_command(CMD_DEVICE_ID, [0]*10+[id_l, id_h]))
read_response()

# def pub_mqtt(jsonrow):
# cmd = ['mosquitto_pub', '-h', MQTT_HOST, '-t', MQTT_TOPIC, '-s']
# print('Publishing using:', cmd)
# with subprocess.Popen(cmd, shell=False, bufsize=0, stdin=subprocess.PIPE).stdin as f:
# json.dump(jsonrow, f)


if __name__ == "__main__":
#cmd_set_sleep(0)
cmd_firmware_ver()
cmd_set_working_period(PERIOD_CONTINUOUS)
cmd_set_mode(MODE_QUERY);

#while True:

#cmd_set_sleep(0)
pm25 = 0
pm10 = 0
n = 0
for t in range(30):
values = cmd_query_data();
if values is not None and len(values) == 2:
n += 1
print(t+1, "PM2.5: ", values[0], ", PM10: ", values[1])
pm25 += values[0]
pm10 += values[1]
time.sleep(1)

if pm25 != 0 and pm10 != 0:
avg_pm25 = round(pm25/n, 1)
avg_pm10 = round(pm10/n, 1)
with open(JSON_FILE, 'w') as outfile: # overwrite
data = {'pm25': avg_pm25, 'pm10': avg_pm10, 'time': time.strftime("%d/%m/%Y %H:%M:%S")}
json.dump(data, outfile)
print("Save! avg_pm25: ", str(avg_pm25), "avg_pm10: ", str(avg_pm10), "time: ", str(time.strftime("%d/%m/%Y %H:%M:%S")))
else:
print("Error: pm2.5, pm10 value are", pm25, pm10)

2. update_google_sheet.py

  • This script reads weather data from BME280 sensor and averaged PM2.5 log from pm25.json file
  • Delete pm25.json file
  • Push a new record to Google Sheet. (The dashboard is automatically updated right away and the published google sheet website is automatically updated every 5 minutes).
# -*- coding: utf-8 -*-
# python 2.7

# import many libraries
from __future__ import print_function
from googleapiclient.discovery import build
from httplib2 import Http
from oauth2client import file, client, tools
from oauth2client.service_account import ServiceAccountCredentials
import bme280
import datetime
import json
import os
import requests

# My Spreadsheet ID ... See google documentation on how to derive this
MY_SPREADSHEET_ID = 'CHANGE_THIS_TO_YOUR_SHEET_ID'


def update_sheet(sheetname, temperature, pressure, humidity, pm25=None, pm10=None):
"""update_sheet method:
appends a row of a sheet in the spreadsheet with the
the latest temperature, pressure and humidity sensor data
"""
# authentication, authorization step
SCOPES = 'https://www.googleapis.com/auth/spreadsheets'
creds = ServiceAccountCredentials.from_json_keyfile_name('bme280-277321-f53e48a3ec9f.json', SCOPES)
service = build('sheets', 'v4', http=creds.authorize(Http()))

# Call the Sheets API, append the next row of sensor data
# values is the array of rows we are updating, its a single row
values = [ [ str(datetime.datetime.now()), temperature, pressure, humidity, pm25, pm10 ] ] # 'Temperature', temperature, 'Pressure', pressure, 'Humidity', humidity ] ]
body = { 'values': values }
# call the append API to perform the operation
result = service.spreadsheets().values().append(
spreadsheetId=MY_SPREADSHEET_ID,
range=sheetname + '!A1:G1',
valueInputOption='USER_ENTERED',
insertDataOption='INSERT_ROWS',
body=body).execute()


def readPMLog(file_path):
try:
with open(file_path) as f:
data = json.load(f)
return data['pm25'], data['pm10']
os.remove(pm25_db_path)
except:
return None, None


def main():
"""main method:
reads the BME280 chip to read the three sensors, then
call update_sheets method to add that sensor data to the spreadsheet
"""
bme = bme280.Bme280()
bme.set_mode(bme280.MODE_FORCED)
tempC, pressure, humidity = bme.get_data()
pressure = pressure/100.

pm25_db_path = '/home/pi/pm25.json'
pm25, pm10 = readPMLog(pm25_db_path)

# print(str(datetime.datetime.now()))
# print ('Temperature: %f °C' % tempC)
# print ('Pressure: %f hPa' % pressure)
# print ('Humidity: %f %%rH' % humidity)
print(str(datetime.datetime.now()), ',', str(tempC), ',', str(pressure), ',', str(humidity),
',', str(pm25), ',', str(pm10))
# # alert to line bot (You need to setup your own line bot webhook)
# if pm25 > 10:
# alert_msg = "PM2.5 Alert: " + str(pm25) + " µg/m³"
# lineid = "XXXXXXXXXXXXXXXXXXXXXXX"
# url = 'http://your_line_bot_webhook.com/push.php?lineid='+lineid+'&msg='+alert_msg
# requests.get(url)
# send log to google sheet
update_sheet("Sheet1", tempC, pressure, humidity, pm25, pm10)


if __name__ == '__main__':
main()

3. Set up crontab schedule to run the scripts automatically

  • Run “crontab -e” to create/edit crontab
  • Change the path and how you want to run it all you want, and put the code below in your crontab setting.
  • “>> log.txt 2>&1” at the end mean save the log as well when the script get run
  • In this example, the cron jobs will run log_pm25_sample.py every 9th minute and update_google_sheet.py every 10th minute.
*/9 * * * * python log_pm25_sample.py
*/10 * * * * (cd /home/pi/bme280-project/python-bme280/; python update_google_sheet.py >> log.txt 2>&1)

PM2.5 prediction API

You can also use some air quality API to get the current value and the forecast as well. In this case, I use Tomorrow.io API and connected to my LINE chat bot to send me an alert when the current/future PM2.5 value exceed the threshold I set. The PM2.5 value from the API doesn’t really match with the real local pm2.5 values, but the up/down trends are pretty reliable.

API: https://www.tomorrow.io/weather-api/ Free 500 calls/day, 25 calls/hr, 3 calls/s

--

--