Project by James; Environment Monitor - V2

I’ve just shared a new project: "Environment Monitor - V2"



This project started due to the smoke haze that enveloped our area during the 2019 and 2020 bushfires. I wanted something to check the air quality of my home and maybe find areas where it needed better sealing. In addition, the BMP280 sensor was on special, so it was built into the project as well. Given how easy it has become to use a Pi Zero, I’ve developed this as a quick and easy tool to be able to determine whether there’s any haze or contaminants which are present in the area this is located in remotely, which can then be forwarded anywhere in the world.

Read more

8 Likes

Hi Jim,

Good to see your project up! Awesome UI using Flask. The air quality sensor is quite interesting.

Liam

5 Likes

Hey James,

Thanks for sharing your project! An array of sensors similar to these strategically deployed with some LiPos and LoRa or similar technology such as your Version One of the project could be very useful for plenty of air quality services such as dust from industrial applications, or bushfires as you’ve noted.

5 Likes

Hi Jim,

Siiiick project, I wonder if it would be possible to rejig a couple of bits so it works with Cores new Piico sensors? and get the price down a bit (the Enviro+ board looks awesome but costs a bit).

@Bryce
LoRa would be perfect for this, getting the data into discrete packets is sometimes and issue but Jim’s method of doing a 5 min average is perfect!! I’m still doing some testing on one of my projects to monitor packet reliability for peer-peer comms as LoRaWAN rolls over to V3 (usually solved by having overlapping gateways).

4 Likes

I bought a couple of Raspberry Pi Pico’s and some of the Piico Dev sensors, yet to try them out.

The Enviro is an expensive board for what it does, I think most of the expense is in the Display, OLED’s are not cheap. Also the OLED is too small to be really effective and in the end is not really needed. The board being mounted on the Pi Zero, close to the processor, makes the on board temp and humidity sensors useless. Even with a Fan the sensor is influenced by the Pi too much.

The Air Quality sensor is also expensive but works really well.

I might have a look at the Piico sensors, using the current system with out the Enviro board.

Will post what I find, here.

Cheers
Jim

5 Likes

Looking at what the Pimoroni Enviro does and comparing to Piico boards and others.

Pimoroni Enviro
BME280 temperature, pressure, humidity sensor
LTR-559 light and proximity sensor
MICS6814 analog gas sensor
MEMS microphone
0.96" colour LCD (160x80)

PiicoDev boards
PiicoDev Atmospheric Sensor BME280SKU: CE07503 $11.40
PiicoDev OLED Display Module (128x64) SSD1306 SKU: CE07911 $11.95
PiicoDev Adapter for Raspberry Pi SKU: CE07690 $3.95

Other boards to get same sensor function as the Enviro
Pimoroni LTR-559 Light & Proximity Sensor Breakout SKU: CE05970 $17.50
Adafruit I2S MEMS Microphone Breakout – SPH0645LM4H SKU: ADA3421 $12.55
Fermion: MEMS Gas Sensor - MiCS-5524 (Breakout) SKU: SEN0440 $15.70

Total cost $73.05 compare to Enviro of $99.95 (noting black & white display compared to colour)

But using modules like the PiicoDev range allows much more flexibility. The above was an exercise to see how to make exactly the same. Probably I would use the PiicoDev Pressure Sensor MS5637 and PiicoDev Precision Temperature Sensor TMP117 due to more accuracy rather than the BME280 and sacrificing humidity sensing.

I think Gas sensing would be better left to something more accurate. The sensors only return a resistance level which you have to play with the get PPM. It would be very easy to misinterpret the readings in code. If it was a storage area or where detecting gas leakage was a priority then the sensors could be used but with adequately designed code.

I dont have all the PiicDev sensors I need, so order to be placed. When I have worked through them I will post changes to the Python & HTML scripts from my project.

Cheers
Jim

2 Likes

Hey Jim,

Awesome, super quick comparison as well! I’m keen to see this one get remixed.

Excellent point about sensing gas, if it was part of a safety system there would be a whole ton of safety factors that also come into play. An interesting note about the sensor using a resistance!

Liam

1 Like

Well parts on the way.
Tested the PiicoDev Ambient Light Sensor VEML6030, works fine, name changes to code only. Very Simple.

But I have to say going from a Pi 4B GUI to a Pi Zero GUI is painful.
I am starting from scratch, flashed ‘Bullseye’ onto a SD card and set it up on a Pi Zero.

Might look into running Thorny on PC and connect to Pi via SSH. Previous programming has been Nano and SSH, a bit of a pain, GUI is so much better.

Cheers
Jim

3 Likes

Python Code so far.
Only PMS5003 and Light Sensor available, waiting on arrival of other items.
Runs ok and output is from the Thonny Console screen.

Lots of commented out code.
Will have to determine how to activate the LCD screen, the Enviro sensor had a proximity function, the PiicoDev does not. Plan to use the Touch Pad, but forgot to order it.

Output.

|--------------------------------------|
|        03 Dec 2021  18:58:35         |
|----------------------------------------------------------------|
| Temperature|  Pressure  |            | Light Lux  |            |
|      0.00  |      0.00  |            |      4.00  |            |
|------------|------------|--------------------------------------|
|            |            |            |            |            |
|            |            |            |            |            |
|------------|------------|--------------------------------------|
|   PM 0.3   |   PM 0.5   |   PM 1.0   |   PM 2.5   |  PM 10.0   |
|      669   |      202   |       17   |        0   |        0   |
|----------------------------------------------------------------|
   Run time = 00:07:01

Python Code.

##############################################################################
#   Environment Sensor V3.0
#   - Temperature, Pressure, Lux, Noise
#   - Particalection 1.0 2.5 10 microns
#
# enviro3.py
#
# Display sensor readings on the LCD
#
# Adapted from Environment Sernsor V2.1
#
# 03 Nov 2021 Adapt for use with PiicoDev devices.
#             Light and Particle only devices available.
#
##############################################################################
#!/usr/bin/env python3
import os
import datetime
import math
#import sounddevice
import numpy
#import board
#import busio
import base64
import multiprocessing
import logging
import threading
import json
#import serial
#import RPi.GPIO as IO

from PiicoDev_VEML6030 import PiicoDev_VEML6030
from PiicoDev_MS5637 import PiicoDev_MS5637
from PiicoDev_TMP117 import PiicoDev_TMP117
from PiicoDev_Unified import sleep_ms
from PiicoDev_SSD1306 import *

from time import sleep, time, asctime, localtime, strftime, gmtime
from pms5003 import PMS5003, ReadTimeoutError as pmsReadTimeoutError
from math import ceil, floor
from subprocess import call
from importlib import import_module
from flask import Flask, render_template, url_for, request, Response

#########################################################################
# Interface setup
#########################################################################
lightVEML6030 = PiicoDev_VEML6030()
#pressureMS5637 = PiicoDev_MS5637()
#tempTMP117 = PiicoDev_TMP117()
pms5003 = PMS5003(device='/dev/ttyAMA0',baudrate=9600,pin_enable=22,pin_reset=27)
#uart = serial.Serial("/dev/ttyS0", baudrate=9600, timeout=0.25) # waits 1/4 second for port

#IO.setmode(IO.BCM)   # Set pin numbering
#IO.setup(4,IO.OUT)   # Fan controller on GPIO 4
#pwm = IO.PWM(4,1000) # PWM frequency
#pwm.start(100)       # Duty cycle
##############################################################################
# LCD Setup
##############################################################################
#displaySSD1306 = create_PiicoDev_SSD1306()

# text(string, x, y, colour)  colour = 1 (white), 0 (black)
# needs font-pet-me-128.dat in running directory
# string up to 16 characters, x,y upper left corner
#displaySSD1306.text("Hello, World!", 0,0, 1)
#displaySSD1306.text(myString, 0,15, 1)
#displaySSD1306.text(str(myNumber), 0,30, 1)
#displaySSD1306.text("{:.2f}".format(myNumber), 0,45, 1)
#displaySSD1306.show()
#PiicoDev_SSD1306.poweroff()
#PiicoDev_SSD1306.poweron()
#PiicoDev_SSD1306.setContrast(contrast) contrast = 0 to 255
#create_PiicoDev_SSD1306(bus=, freq=, sda=, scl=, addr=0x3C)
#   bus = 1 = RPi. freq = ignored RPi, sda, scl = only Pi Pico, addr = 0x3C or 0x3D

#########################################################################
# Variables
#########################################################################
record = {}                                  # sensor readings now
data = []                                    # current sensor readings for up to last 5 minutes
days = []                                    # holds all the saved data
samples = 300                                # Each file record is 5 minutes, 60 x 5 = 300 seconds
samples_per_day = 24 * 3600 // samples       # // rounds down and truncates

Time_Start = datetime.datetime.now()
Seconds_Start = Time_Start.timestamp()

#########################################################################
# Set up web application
#########################################################################
app = Flask(__name__)
app.config['TEMPLATES_AUTO_RELOAD'] = True

log = logging.getLogger("werkzeug")
log.disabled = True
run_flag = True

#########################################################################
# Create a filename from year and day number, ie 365 days in year
#########################################################################
def filename(t):
    return strftime("data/%Y_%j", localtime(t))

#########################################################################
# Display LCD Message - 3 lines of 15 characters, centered or not
#########################################################################
def Display_MSG(L1,L2,L3,c):

    x = 2    # leave 2 pixels for non centered message
#    displaySSD1306.rect(0,0,127,63,1)
    if c == True:
        x = (16 - len(L1)) / 2
#    displaySSD1306.text(L1,x,1,1)
    if c == True:
        x = (16 - len(L2)) / 2
#    displaySSD1306.text(L2,x,21,1)
    if c == True:
        x = (16 - len(L3)) / 2
#    displaySSD1306.text(L3,x,42,1)

#    displaySSD1306.show()

    return
#########################################################################
# Display text and data
#########################################################################
def Display_Data(L1T,L2T,L3T,L1D,L2D,L3D):

#    displaySSD1306.rect(0,0,127,63,1)

#    displaySSD1306.text(L1T,2,1,1)
#    displaySSD1306.text(L1D,64,1,1)
#    displaySSD1306.text(L2T,2,21,1)
#    displaySSD1306.text(L2D,64,21,1)
#    displaySSD1306.text(L3T,2,42,1)
#    displaySSD1306.text(L3D,64,42,1)

#    displaySSD1306.show()

    return
#########################################################################
# LCD Display Screens
#########################################################################
def Info():
    Time_Now = datetime.datetime.now()
    Display_MSG("Environment","Monitor",Time_Now.strftime("%d/%m/%y  %H:%M"),True)
    return

def LCD_Temp_Pressure():
    Display_Data(" Temp."," "," Press.",
                 "{:8.02f}".format(record['temp']),
                 "{:8.02f}".format(record['humi'])," ")
    return

def LCD_PM():
    Display_Data(" PM 10.0"," PM  2.5"," PM  1.0",
                 "{:5.0f}".format(record['pm100']),
                 "{:5.0f}".format(record['pm25']),
                 "{:5.0f}".format(record['pm10']))
    return

def LCD_Light():
    Display_Data(" Lux"," "," ",
                 "{:8.02f}".format(record['lux'])," "," ")
    return
#########################################################################
# LCD Screen selector
#########################################################################
def LCD_Screen_Selector(s):
    switcher = {
        0: Info,
        1: LCD_Temp_Pressure,
        2: LCD_PM,
        3: LCD_Light
    }
#    PiicoDev_SSD1306.setContrast(255)
    func = switcher.get(s, lambda: "Invalid Screen")
    func()
    return
#########################################################################
# LCD Display
#########################################################################
def LCD():
    prox_trig = False
    LCD_OFF = False
    LCD_Screen = 0

    Display_MSG("Environment","Monitor",Time_Start.strftime("%d/%m/%y  %H:%M"),True)
    LCD_Display_Timer = datetime.datetime.now().timestamp() + 30

    while run_flag:
#  Add something to trigger display ON, cap touch pad or simple switch
#      replaces if ltr559.get_proximity() > 600:
#        if ltr559.get_proximity() > 600:
#            if prox_trig == False:
#                if LCD_OFF == True:
#                    LCD_Screen = 0
#                LCD_Screen_Selector(LCD_Screen)
#                LCD_Display_Timer = datetime.datetime.now().timestamp() + 30
#                LCD_OFF = False
#                LCD_Screen += 1
#                if LCD_Screen > 5:
#                    LCD_Screen = 0
#                prox_trig = True
#            else:
#                prox_trig = False

        if LCD_Display_Timer < datetime.datetime.now().timestamp():   # LCD turns off after 30 seconds
#            PiicoDev_SSD1306.setContrast(0)
#            displaySSD1306.rect(0,0,127,63,1)
#            displaySSD1306.show()
            LCD_OFF = True

        sleep(0.3)

#########################################################################
# Converts Time String into integer
#########################################################################
def record_time(r):
    t = r['time'].split()[3].split(':')
    return int(t[0]) * 60 + int(t[1])

#########################################################################
#  Adds Sensor reads to list for 5 minute period
#########################################################################
def add_record(day, record):
    if record_time(record) > 0:                   # If not the first record of the day
        while len(day) == 0 or record_time(day[-1]) < record_time(record) - samples // 60: # Is there a gap
            if len(day):
                filler = dict(day[-1])            # Duplicate the last record to forward fill
                t = record_time(filler) + samples // 60
            else:
                filler = dict(record)             # Need to back fill
                t = 0                             # Only happens if the day is empty so most be the first entry
            old_time = filler["time"]             # Need to fix the time field
            colon_pos = old_time.find(':')
            filler["time"] = old_time[:colon_pos - 2] + ("%02d:%02d" % (t / 60, t % 60)) + old_time[colon_pos + 3:]
            day.append(filler)
    day.append(record)

#########################################################################
# Averge Sensor readings for save to file, 5 minute period
#########################################################################
def sum_data(data):
    totals = {"time" : data[0]["time"]}
    keys = list(data[0].keys())
    keys.remove("time")
    for key in keys:
        totals[key] = 0
    for d in data:
        for key in keys:
            totals[key] += d[key]
    count = float(len(data))
    for key in keys:
        totals[key] = round(totals[key] / count, 1)
    return totals

#########################################################################
# Read Sensors, Version 3 pressure temperature lux particles
#########################################################################
def read_data(time):

#    print ("read_data")

#    particles = []

#    pressure = pressureMS5637.read_pressure()
#    altitude_m = pressure.read_altitude()

#    temperature = tempTMP117.readTempC() # Celsius
#    tempF = tempSensor.readTempF() # Farenheit
#    tempK = tempSensor.readTempK() # Kelvin

    lux = lightVEML6030.read()

#    Rec = sounddevice.rec(8000,16000,blocking=True,channels=1,dtype='float64')
#    RecAbs = numpy.abs(Rec)
#    RecMax = numpy.max(RecAbs)
#    noise = 20 * math.log(RecMax/1)                      # convert level to dB

    while True:
        try:
            particles = pms5003.read()
            break
        except RuntimeError as e:
            print("Particle read failed:", e.__class__.__name__)
            if not run_flag:
                raise e
            pms5003.reset()
            sleep(30)

    pm100 = particles.pm_per_1l_air(10.0)
    pm50 = particles.pm_per_1l_air(5.0)
    pm25 = particles.pm_per_1l_air(2.5)
    pm10 = particles.pm_per_1l_air(1.0)
    pm5 = particles.pm_per_1l_air(0.5)
    pm3 = particles.pm_per_1l_air(0.3)

    record = {
        'time' : asctime(localtime(time)),
#        'temp' : round(temperature,1),
#        'pres' : round(pressure,1),
        'temp' : 0,
        'pres' : 0,
        'lux'  : round(lux),
#        'noise': round(noise,1),
        'pm03' : pm3,
        'pm05' : pm5,
        'pm10' : pm10,
        'pm25' : pm25,
        'pm50' : pm50,
        'pm100': pm100
    }
    return record
#########################################################################
# Reads Sensors, place in list, runs as separate thead
#########################################################################
def Sensors():
    global record, data

    sleep(2)
    last_file = None
    while run_flag:
        t = int(floor(time()))
        record = read_data(t)
        data = data[-(samples - 1):] + [record]                  # Keep five minutes
        if t % samples == samples - 1 and len(data) == samples:  # At the end of a 5 minute period?
            totals = sum_data(data)
            fname = filename(t - (samples - 1))
            with open(fname, "a+") as f:
                f.write(json.dumps(totals) + '\n')
            if not days or (last_file and last_file != fname):
                days.append([])
            last_file = fname
            add_record(days[-1], totals)                         # Add to today, filling any gap from last reading
                                                                 # if it had been stopped
        sleep(max(t + 1 - time(), 0.1))

#########################################################################
#########################################################################
# Startup Message on terminal
#########################################################################
def Start_MSG():
    print (" ")
    print ("       Environment Monitor - V3")
    print (" ")
    print (" ")
    print ("Start Time ",Time_Start.strftime("%d %b %Y  %H:%M:%S"))
    print (" ")

    return
#########################################################################
# Print Sensor Readings
#   Displaying 0.3 & 0.5 to indicate PMS5003 is working.
#########################################################################
def print_Sensors():
    global Time_Start

    dt = datetime.datetime.now()
    t = int(dt.timestamp() - Seconds_Start)         # get seconds since start
    h = int(t / 3600)                            # calc hours minutes & seconds since start
    m = int((t - (h * 3600)) / 60)
    s = int(t - (h * 3600 + m *60))

    print ("|--------------------------------------|")
    print ("|       ",dt.strftime("%d %b %Y  %H:%M:%S"),"        |")
    print ("|----------------------------------------------------------------|")
    print ("| Temperature|  Pressure  |            | Light Lux  |            |")
    print ("|  {:8.02f}  |  {:8.02f}  |            |  {:8.02f}  |            |"
         .format(record['temp'],record['pres'],record['lux'],))
    print ("|------------|------------|--------------------------------------|")
    print ("|            |            |            |            |            |")
    print ("|            |            |            |            |            |")
    print ("|------------|------------|--------------------------------------|")
    print ("|   PM 0.3   |   PM 0.5   |   PM 1.0   |   PM 2.5   |  PM 10.0   |")
    print ("|  {:7.0f}   |  {:7.0f}   |  {:7.0f}   |  {:7.0f}   |  {:7.0f}   |"
         .format(record['pm03'],record['pm05'],record['pm10'],record['pm25'],record['pm100']))
    print ("|----------------------------------------------------------------|")
    print ("   Run time = {:02d}:{:02d}:{:02d}".format(h,m,s))
    print (" ")

    return                               # return time for next display

#########################################################################
# Terminal Display
#########################################################################
def Terminal():

    Start_MSG()
    Print_Sensor_Timer = Seconds_Start + 60                               # display every minute
    while run_flag:
        if Print_Sensor_Timer < datetime.datetime.now().timestamp():
            print_Sensors()
            Print_Sensor_Timer = datetime.datetime.now().timestamp() + 60 # set time for next display
        sleep(1.0)

#########################################################################
#########################################################################
# Reduce large sample (year) to what can be displayed on web page
#########################################################################
def compress_data(ndays, nsamples):
    cdata = []
    for day in days[-(ndays + 1):]:
        for i in range(0, len(day), nsamples):
            cdata.append(sum_data(day[i : i + nsamples]))
    length = ndays * samples_per_day // nsamples
    return json.dumps(cdata[-length:])

# 300 @ 1s = 5m
# 288 @ 5m = 24h
# 336 @ 30m = 1w
# 372 @ 2h = 31d
# 365 @ 1d = 1y
#########################################################################
#  Web page access
#########################################################################
@app.route('/')
def index():
    return render_template('index.html')

@app.route('/readings')
def readings():
    arg = request.args["fan"]
    pwm.ChangeDutyCycle(int(arg))
    return render_template('readings.html', **record)

@app.route('/graph')
def graph():
    arg = request.args["time"]
    if arg == 'day':
        last2 = []
        for day in days[-2:]:
            last2 += day
        return json.dumps(last2[-samples_per_day:])
    if arg == 'week':
        return compress_data(7, 30 * 60 // samples)
    if arg == 'month':
        return compress_data(31, 120 * 60 // samples)
    if arg == 'year':
        return compress_data(365, samples_per_day)
    return json.dumps(data)

#########################################################################
#  Read all data that has been saved into a list for display on the graph
#########################################################################
def Get_Saved_Data():
    global days

    if not os.path.isdir('data'):                     # create folder if not present
        os.makedirs('data')
    files =  sorted(os.listdir('data'))               # get all the file names
    for f in files:
        day = []
        with open('data/' + f, 'r') as file:          # open a file
            for line in file.readlines():             # read each line
                record = json.loads(line)
                add_record(day, record)
        days.append(day)
    return

#########################################################################
#########################################################################
# Main Run Loop  Read the day data into a list from all the saved files
#                Start sensor reads in background
#                Start Flask web server as separate process
#########################################################################
if __name__ == '__main__':

    Sensors_thread = threading.Thread(target = Sensors)
    LCD_thread = threading.Thread(target = LCD)
    Term_thread = threading.Thread(target = Terminal)

    record = read_data(time())                        # first reading is not correct data
    Get_Saved_Data()                                  # get all saved data from all files
    Sensors_thread.start()                            # start sensor reads and saves
    LCD_thread.start()                                # start LCD display
    Term_thread.start()                               # start LCD display
    app.run(host='0.0.0.0', port=5000, threaded=True, debug=False)
# execution waits here till web process stopped

    PiicoDev_SSD1306.setContrast(0)
    print (" ")
    print ("Waiting for background to quit")
    run_flag = False                                  # stops background thread from running
    Sensors_thread.join()                          # waits for background threads to stop
    LCD_thread.join()
    Term_thread.join()

#########################################################################
#########################################################################
#########################################################################
#########################################################################
#########################################################################
#########################################################################
1 Like