commit bcf2c73bdfaf3f9376d2a4b1c42f2df8424efb5d Author: Paul Beech Date: Tue May 12 04:24:11 2020 +0100 Initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5824813 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +build/ +_build/ +*.o +*.so +*.a +*.py[cod] +*.egg-info +dist/ +__pycache__ +.DS_Store +*.deb +*.dsc +*.build +*.changes +*.orig.* +packaging/*tar.xz +library/debian/ +.coverage +.pytest_cache +.tox +.vscode/ diff --git a/.stickler.yml b/.stickler.yml new file mode 100644 index 0000000..2466815 --- /dev/null +++ b/.stickler.yml @@ -0,0 +1,5 @@ +--- +linters: + flake8: + python: 3 + max-line-length: 160 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..79decd6 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,25 @@ +language: python +sudo: false +cache: pip + +git: + submodules: true + +matrix: + include: + - python: "2.7" + env: TOXENV=py27 + - python: "3.5" + env: TOXENV=py35 + +install: + - pip install --ignore-installed --upgrade setuptools pip tox coveralls + +script: + - cd library + - tox -vv + +after_success: if [ "$TOXENV" == "py35" ]; then coveralls; fi + +notifications: + email: false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f85d8b4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Paul Beech + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d2bba49 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +LIBRARY_VERSION=$(shell grep version library/setup.cfg | awk -F" = " '{print $$2}') +LIBRARY_NAME=$(shell grep name library/setup.cfg | awk -F" = " '{print $$2}') + +.PHONY: usage install uninstall +usage: + @echo "Library: ${LIBRARY_NAME}" + @echo "Version: ${LIBRARY_VERSION}\n" + @echo "Usage: make , where target is one of:\n" + @echo "install: install the library locally from source" + @echo "uninstall: uninstall the local library" + @echo "check: peform basic integrity checks on the codebase" + @echo "python-readme: generate library/README.rst from README.md" + @echo "python-wheels: build python .whl files for distribution" + @echo "python-sdist: build python source distribution" + @echo "python-clean: clean python build and dist directories" + @echo "python-dist: build all python distribution files" + @echo "python-testdeploy: build all and deploy to test PyPi" + @echo "tag: tag the repository with the current version" + +install: + ./install.sh + +uninstall: + ./uninstall.sh + +check: + @echo "Checking for trailing whitespace" + @! grep -IUrn --color "[[:blank:]]$$" --exclude-dir=sphinx --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO + @echo "Checking for DOS line-endings" + @! grep -IUrn --color " " --exclude-dir=sphinx --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile + @echo "Checking library/CHANGELOG.txt" + @cat library/CHANGELOG.txt | grep ^${LIBRARY_VERSION} + @echo "Checking library/${LIBRARY_NAME}/__init__.py" + @cat library/${LIBRARY_NAME}/__init__.py | grep "^__version__ = '${LIBRARY_VERSION}'" + +tag: + git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" + +python-readme: library/README.rst + +python-license: library/LICENSE.txt + +library/README.rst: README.md library/CHANGELOG.txt + pandoc --from=markdown --to=rst -o library/README.rst README.md + echo "" >> library/README.rst + cat library/CHANGELOG.txt >> library/README.rst + +library/LICENSE.txt: LICENSE + cp LICENSE library/LICENSE.txt + +python-wheels: python-readme python-license + cd library; python3 setup.py bdist_wheel + cd library; python setup.py bdist_wheel + +python-sdist: python-readme python-license + cd library; python setup.py sdist + +python-clean: + -rm -r library/dist + -rm -r library/build + -rm -r library/*.egg-info + +python-dist: python-clean python-wheels python-sdist + ls library/dist + +python-testdeploy: python-dist + twine upload --repository-url https://test.pypi.org/legacy/ library/dist/* + +python-deploy: check python-dist + twine upload library/dist/* diff --git a/README.md b/README.md new file mode 100644 index 0000000..5fb9fe6 --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# Enviro+ + +Designed for environmental monitoring, Enviro+ lets you measure air quality (pollutant gases and particulates), temperature, pressure, humidity, light, and noise level. Learn more - https://shop.pimoroni.com/products/enviro-plus + +[![Build Status](https://travis-ci.com/pimoroni/enviroplus-python.svg?branch=master)](https://travis-ci.com/pimoroni/enviroplus-python) +[![Coverage Status](https://coveralls.io/repos/github/pimoroni/enviroplus-python/badge.svg?branch=master)](https://coveralls.io/github/pimoroni/enviroplus-python?branch=master) +[![PyPi Package](https://img.shields.io/pypi/v/enviroplus.svg)](https://pypi.python.org/pypi/enviroplus) +[![Python Versions](https://img.shields.io/pypi/pyversions/enviroplus.svg)](https://pypi.python.org/pypi/enviroplus) + +# Installing + +You're best using the "One-line" install method if you want all of the UART serial configuration for the PMS5003 particulate matter sensor to run automatically. + +## One-line (Installs from GitHub) + +``` +curl -sSL https://get.pimoroni.com/enviroplus | bash +``` + +**Note** report issues with one-line installer here: https://github.com/pimoroni/get + +## Or... Install and configure dependencies from GitHub: + +* `git clone https://github.com/pimoroni/enviroplus-python` +* `cd enviroplus-python` +* `sudo ./install.sh` + +**Note** Raspbian Lite users may first need to install git: `sudo apt install git` + +## Or... Install from PyPi and configure manually: + +* Run `sudo pip install enviroplus` + +**Note** this wont perform any of the required configuration changes on your Pi, you may additionally need to: + +* Enable i2c: `raspi-config nonint do_i2c 0` +* Enable SPI: `raspi-config nonint do_spi 0` + +And if you're using a PMS5003 sensor you will need to: + +* Enable serial: `raspi-config nonint set_config_var enable_uart 1 /boot/config.txt` +* Disable serial terminal: `sudo raspi-config nonint do_serial 1` +* Add `dtoverlay=pi3-miniuart-bt` to your `/boot/config.txt` + +And install additional dependencies: + +``` +sudo apt install python-numpy python-smbus python-pil python-setuptools +``` + +## Help & Support + +* GPIO Pinout - https://pinout.xyz/pinout/enviro_plus +* Support forums - http://forums.pimoroni.com/c/support +* Discord - https://discord.gg/hr93ByC diff --git a/examples/adc.py b/examples/adc.py new file mode 100644 index 0000000..a345d23 --- /dev/null +++ b/examples/adc.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 + +import time +from enviroplus import gas +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""adc.py - Print readings from the MICS6814 Gas sensor. + +Press Ctrl+C to exit! + +""") + +gas.enable_adc() +gas.set_adc_gain(4.096) + +try: + while True: + readings = gas.read_all() + logging.info(readings) + time.sleep(1.0) +except KeyboardInterrupt: + pass diff --git a/examples/all-in-one-no-pm.py b/examples/all-in-one-no-pm.py new file mode 100644 index 0000000..de8ab06 --- /dev/null +++ b/examples/all-in-one-no-pm.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 + +import time +import colorsys +import os +import sys +import ST7735 +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +from bme280 import BME280 +from enviroplus import gas +from subprocess import PIPE, Popen +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont +from fonts.ttf import RobotoMedium as UserFont +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""all-in-one.py - Displays readings from all of Enviro plus' sensors +Press Ctrl+C to exit! +""") + +# BME280 temperature/pressure/humidity sensor +bme280 = BME280() + +# Create ST7735 LCD display class +st7735 = ST7735.ST7735( + port=0, + cs=1, + dc=9, + backlight=12, + rotation=270, + spi_speed_hz=10000000 +) + +# Initialize display +st7735.begin() + +WIDTH = st7735.width +HEIGHT = st7735.height + +# Set up canvas and font +img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +draw = ImageDraw.Draw(img) +path = os.path.dirname(os.path.realpath(__file__)) +font_size = 20 +font = ImageFont.truetype(UserFont, font_size) + +message = "" + +# The position of the top bar +top_pos = 25 + + +# Displays data and text on the 0.96" LCD +def display_text(variable, data, unit): + # Maintain length of list + values[variable] = values[variable][1:] + [data] + # Scale the values for the variable between 0 and 1 + vmin = min(values[variable]) + vmax = max(values[variable]) + colours = [(v - vmin + 1) / (vmax - vmin + 1) for v in values[variable]] + # Format the variable name and value + message = "{}: {:.1f} {}".format(variable[:4], data, unit) + logging.info(message) + draw.rectangle((0, 0, WIDTH, HEIGHT), (255, 255, 255)) + for i in range(len(colours)): + # Convert the values to colours from red to blue + colour = (1.0 - colours[i]) * 0.6 + r, g, b = [int(x * 255.0) for x in colorsys.hsv_to_rgb(colour, 1.0, 1.0)] + # Draw a 1-pixel wide rectangle of colour + draw.rectangle((i, top_pos, i + 1, HEIGHT), (r, g, b)) + # Draw a line graph in black + line_y = HEIGHT - (top_pos + (colours[i] * (HEIGHT - top_pos))) + top_pos + draw.rectangle((i, line_y, i + 1, line_y + 1), (0, 0, 0)) + # Write the text at the top in black + draw.text((0, 0), message, font=font, fill=(0, 0, 0)) + st7735.display(img) + + +# Get the temperature of the CPU for compensation +def get_cpu_temperature(): + process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE, universal_newlines=True) + output, _error = process.communicate() + return float(output[output.index('=') + 1:output.rindex("'")]) + + +# Tuning factor for compensation. Decrease this number to adjust the +# temperature down, and increase to adjust up +factor = 2.25 + +cpu_temps = [get_cpu_temperature()] * 5 + +delay = 0.5 # Debounce the proximity tap +mode = 0 # The starting mode +last_page = 0 +light = 1 + +# Create a values dict to store the data +variables = ["temperature", + "pressure", + "humidity", + "light", + "oxidised", + "reduced", + "nh3"] + +values = {} + +for v in variables: + values[v] = [1] * WIDTH + +# The main loop +try: + while True: + proximity = ltr559.get_proximity() + + # If the proximity crosses the threshold, toggle the mode + if proximity > 1500 and time.time() - last_page > delay: + mode += 1 + mode %= len(variables) + last_page = time.time() + + # One mode for each variable + if mode == 0: + # variable = "temperature" + unit = "C" + cpu_temp = get_cpu_temperature() + # Smooth out with some averaging to decrease jitter + cpu_temps = cpu_temps[1:] + [cpu_temp] + avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps)) + raw_temp = bme280.get_temperature() + data = raw_temp - ((avg_cpu_temp - raw_temp) / factor) + display_text(variables[mode], data, unit) + + if mode == 1: + # variable = "pressure" + unit = "hPa" + data = bme280.get_pressure() + display_text(variables[mode], data, unit) + + if mode == 2: + # variable = "humidity" + unit = "%" + data = bme280.get_humidity() + display_text(variables[mode], data, unit) + + if mode == 3: + # variable = "light" + unit = "Lux" + if proximity < 10: + data = ltr559.get_lux() + else: + data = 1 + display_text(variables[mode], data, unit) + + if mode == 4: + # variable = "oxidised" + unit = "kO" + data = gas.read_all() + data = data.oxidising / 1000 + display_text(variables[mode], data, unit) + + if mode == 5: + # variable = "reduced" + unit = "kO" + data = gas.read_all() + data = data.reducing / 1000 + display_text(variables[mode], data, unit) + + if mode == 6: + # variable = "nh3" + unit = "kO" + data = gas.read_all() + data = data.nh3 / 1000 + display_text(variables[mode], data, unit) + +# Exit cleanly +except KeyboardInterrupt: + sys.exit(0) diff --git a/examples/all-in-one.py b/examples/all-in-one.py new file mode 100644 index 0000000..6dda607 --- /dev/null +++ b/examples/all-in-one.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 + +import time +import colorsys +import sys +import ST7735 +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +from bme280 import BME280 +from pms5003 import PMS5003, ReadTimeoutError as pmsReadTimeoutError +from enviroplus import gas +from subprocess import PIPE, Popen +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont +from fonts.ttf import RobotoMedium as UserFont +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""all-in-one.py - Displays readings from all of Enviro plus' sensors + +Press Ctrl+C to exit! + +""") + +# BME280 temperature/pressure/humidity sensor +bme280 = BME280() + +# PMS5003 particulate sensor +pms5003 = PMS5003() + +# Create ST7735 LCD display class +st7735 = ST7735.ST7735( + port=0, + cs=1, + dc=9, + backlight=12, + rotation=270, + spi_speed_hz=10000000 +) + +# Initialize display +st7735.begin() + +WIDTH = st7735.width +HEIGHT = st7735.height + +# Set up canvas and font +img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +draw = ImageDraw.Draw(img) +font_size = 20 +font = ImageFont.truetype(UserFont, font_size) + +message = "" + +# The position of the top bar +top_pos = 25 + + +# Displays data and text on the 0.96" LCD +def display_text(variable, data, unit): + # Maintain length of list + values[variable] = values[variable][1:] + [data] + # Scale the values for the variable between 0 and 1 + vmin = min(values[variable]) + vmax = max(values[variable]) + colours = [(v - vmin + 1) / (vmax - vmin + 1) for v in values[variable]] + # Format the variable name and value + message = "{}: {:.1f} {}".format(variable[:4], data, unit) + logging.info(message) + draw.rectangle((0, 0, WIDTH, HEIGHT), (255, 255, 255)) + for i in range(len(colours)): + # Convert the values to colours from red to blue + colour = (1.0 - colours[i]) * 0.6 + r, g, b = [int(x * 255.0) for x in colorsys.hsv_to_rgb(colour, 1.0, 1.0)] + # Draw a 1-pixel wide rectangle of colour + draw.rectangle((i, top_pos, i + 1, HEIGHT), (r, g, b)) + # Draw a line graph in black + line_y = HEIGHT - (top_pos + (colours[i] * (HEIGHT - top_pos))) + top_pos + draw.rectangle((i, line_y, i + 1, line_y + 1), (0, 0, 0)) + # Write the text at the top in black + draw.text((0, 0), message, font=font, fill=(0, 0, 0)) + st7735.display(img) + + +# Get the temperature of the CPU for compensation +def get_cpu_temperature(): + process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE, universal_newlines=True) + output, _error = process.communicate() + return float(output[output.index('=') + 1:output.rindex("'")]) + + +# Tuning factor for compensation. Decrease this number to adjust the +# temperature down, and increase to adjust up +factor = 2.25 + +cpu_temps = [get_cpu_temperature()] * 5 + +delay = 0.5 # Debounce the proximity tap +mode = 0 # The starting mode +last_page = 0 +light = 1 + +# Create a values dict to store the data +variables = ["temperature", + "pressure", + "humidity", + "light", + "oxidised", + "reduced", + "nh3", + "pm1", + "pm25", + "pm10"] + +values = {} + +for v in variables: + values[v] = [1] * WIDTH + +# The main loop +try: + while True: + proximity = ltr559.get_proximity() + + # If the proximity crosses the threshold, toggle the mode + if proximity > 1500 and time.time() - last_page > delay: + mode += 1 + mode %= len(variables) + last_page = time.time() + + # One mode for each variable + if mode == 0: + # variable = "temperature" + unit = "C" + cpu_temp = get_cpu_temperature() + # Smooth out with some averaging to decrease jitter + cpu_temps = cpu_temps[1:] + [cpu_temp] + avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps)) + raw_temp = bme280.get_temperature() + data = raw_temp - ((avg_cpu_temp - raw_temp) / factor) + display_text(variables[mode], data, unit) + + if mode == 1: + # variable = "pressure" + unit = "hPa" + data = bme280.get_pressure() + display_text(variables[mode], data, unit) + + if mode == 2: + # variable = "humidity" + unit = "%" + data = bme280.get_humidity() + display_text(variables[mode], data, unit) + + if mode == 3: + # variable = "light" + unit = "Lux" + if proximity < 10: + data = ltr559.get_lux() + else: + data = 1 + display_text(variables[mode], data, unit) + + if mode == 4: + # variable = "oxidised" + unit = "kO" + data = gas.read_all() + data = data.oxidising / 1000 + display_text(variables[mode], data, unit) + + if mode == 5: + # variable = "reduced" + unit = "kO" + data = gas.read_all() + data = data.reducing / 1000 + display_text(variables[mode], data, unit) + + if mode == 6: + # variable = "nh3" + unit = "kO" + data = gas.read_all() + data = data.nh3 / 1000 + display_text(variables[mode], data, unit) + + if mode == 7: + # variable = "pm1" + unit = "ug/m3" + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(1.0)) + display_text(variables[mode], data, unit) + + if mode == 8: + # variable = "pm25" + unit = "ug/m3" + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(2.5)) + display_text(variables[mode], data, unit) + + if mode == 9: + # variable = "pm10" + unit = "ug/m3" + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(10)) + display_text(variables[mode], data, unit) + +# Exit cleanly +except KeyboardInterrupt: + sys.exit(0) diff --git a/examples/combined.py b/examples/combined.py new file mode 100644 index 0000000..4b8fbdd --- /dev/null +++ b/examples/combined.py @@ -0,0 +1,348 @@ +#!/usr/bin/env python3 + +import time +import colorsys +import sys +import ST7735 +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +from bme280 import BME280 +from pms5003 import PMS5003, ReadTimeoutError as pmsReadTimeoutError +from enviroplus import gas +from subprocess import PIPE, Popen +from PIL import Image +from PIL import ImageDraw +from PIL import ImageFont +from fonts.ttf import RobotoMedium as UserFont +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""all-in-one.py - Displays readings from all of Enviro plus' sensors + +Press Ctrl+C to exit! + +""") + +# BME280 temperature/pressure/humidity sensor +bme280 = BME280() + +# PMS5003 particulate sensor +pms5003 = PMS5003() +time.sleep(1.0) + +# Create ST7735 LCD display class +st7735 = ST7735.ST7735( + port=0, + cs=1, + dc=9, + backlight=12, + rotation=270, + spi_speed_hz=10000000 +) + +# Initialize display +st7735.begin() + +WIDTH = st7735.width +HEIGHT = st7735.height + +# Set up canvas and font +img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +draw = ImageDraw.Draw(img) +font_size_small = 10 +font_size_large = 20 +font = ImageFont.truetype(UserFont, font_size_large) +smallfont = ImageFont.truetype(UserFont, font_size_small) +x_offset = 2 +y_offset = 2 + +message = "" + +# The position of the top bar +top_pos = 25 + +# Create a values dict to store the data +variables = ["temperature", + "pressure", + "humidity", + "light", + "oxidised", + "reduced", + "nh3", + "pm1", + "pm25", + "pm10"] + +units = ["C", + "hPa", + "%", + "Lux", + "kO", + "kO", + "kO", + "ug/m3", + "ug/m3", + "ug/m3"] + +# Define your own warning limits +# The limits definition follows the order of the variables array +# Example limits explanation for temperature: +# [4,18,28,35] means +# [-273.15 .. 4] -> Dangerously Low +# (4 .. 18] -> Low +# (18 .. 28] -> Normal +# (28 .. 35] -> High +# (35 .. MAX] -> Dangerously High +# DISCLAIMER: The limits provided here are just examples and come +# with NO WARRANTY. The authors of this example code claim +# NO RESPONSIBILITY if reliance on the following values or this +# code in general leads to ANY DAMAGES or DEATH. +limits = [[4, 18, 28, 35], + [250, 650, 1013.25, 1015], + [20, 30, 60, 70], + [-1, -1, 30000, 100000], + [-1, -1, 40, 50], + [-1, -1, 450, 550], + [-1, -1, 200, 300], + [-1, -1, 50, 100], + [-1, -1, 50, 100], + [-1, -1, 50, 100]] + +# RGB palette for values on the combined screen +palette = [(0, 0, 255), # Dangerously Low + (0, 255, 255), # Low + (0, 255, 0), # Normal + (255, 255, 0), # High + (255, 0, 0)] # Dangerously High + +values = {} + + +# Displays data and text on the 0.96" LCD +def display_text(variable, data, unit): + # Maintain length of list + values[variable] = values[variable][1:] + [data] + # Scale the values for the variable between 0 and 1 + vmin = min(values[variable]) + vmax = max(values[variable]) + colours = [(v - vmin + 1) / (vmax - vmin + 1) for v in values[variable]] + # Format the variable name and value + message = "{}: {:.1f} {}".format(variable[:4], data, unit) + logging.info(message) + draw.rectangle((0, 0, WIDTH, HEIGHT), (255, 255, 255)) + for i in range(len(colours)): + # Convert the values to colours from red to blue + colour = (1.0 - colours[i]) * 0.6 + r, g, b = [int(x * 255.0) for x in colorsys.hsv_to_rgb(colour, 1.0, 1.0)] + # Draw a 1-pixel wide rectangle of colour + draw.rectangle((i, top_pos, i + 1, HEIGHT), (r, g, b)) + # Draw a line graph in black + line_y = HEIGHT - (top_pos + (colours[i] * (HEIGHT - top_pos))) + top_pos + draw.rectangle((i, line_y, i + 1, line_y + 1), (0, 0, 0)) + # Write the text at the top in black + draw.text((0, 0), message, font=font, fill=(0, 0, 0)) + st7735.display(img) + + +# Saves the data to be used in the graphs later and prints to the log +def save_data(idx, data): + variable = variables[idx] + # Maintain length of list + values[variable] = values[variable][1:] + [data] + unit = units[idx] + message = "{}: {:.1f} {}".format(variable[:4], data, unit) + logging.info(message) + + +# Displays all the text on the 0.96" LCD +def display_everything(): + draw.rectangle((0, 0, WIDTH, HEIGHT), (0, 0, 0)) + column_count = 2 + row_count = (len(variables) / column_count) + for i in range(len(variables)): + variable = variables[i] + data_value = values[variable][-1] + unit = units[i] + x = x_offset + ((WIDTH / column_count) * (i / row_count)) + y = y_offset + ((HEIGHT / row_count) * (i % row_count)) + message = "{}: {:.1f} {}".format(variable[:4], data_value, unit) + lim = limits[i] + rgb = palette[0] + for j in range(len(lim)): + if data_value > lim[j]: + rgb = palette[j + 1] + draw.text((x, y), message, font=smallfont, fill=rgb) + st7735.display(img) + + +# Get the temperature of the CPU for compensation +def get_cpu_temperature(): + process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE, universal_newlines=True) + output, _error = process.communicate() + return float(output[output.index('=') + 1:output.rindex("'")]) + + +def main(): + # Tuning factor for compensation. Decrease this number to adjust the + # temperature down, and increase to adjust up + factor = 2.25 + + cpu_temps = [get_cpu_temperature()] * 5 + + delay = 0.5 # Debounce the proximity tap + mode = 10 # The starting mode + last_page = 0 + + for v in variables: + values[v] = [1] * WIDTH + + # The main loop + try: + while True: + proximity = ltr559.get_proximity() + + # If the proximity crosses the threshold, toggle the mode + if proximity > 1500 and time.time() - last_page > delay: + mode += 1 + mode %= (len(variables) + 1) + last_page = time.time() + + # One mode for each variable + if mode == 0: + # variable = "temperature" + unit = "C" + cpu_temp = get_cpu_temperature() + # Smooth out with some averaging to decrease jitter + cpu_temps = cpu_temps[1:] + [cpu_temp] + avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps)) + raw_temp = bme280.get_temperature() + data = raw_temp - ((avg_cpu_temp - raw_temp) / factor) + display_text(variables[mode], data, unit) + + if mode == 1: + # variable = "pressure" + unit = "hPa" + data = bme280.get_pressure() + display_text(variables[mode], data, unit) + + if mode == 2: + # variable = "humidity" + unit = "%" + data = bme280.get_humidity() + display_text(variables[mode], data, unit) + + if mode == 3: + # variable = "light" + unit = "Lux" + if proximity < 10: + data = ltr559.get_lux() + else: + data = 1 + display_text(variables[mode], data, unit) + + if mode == 4: + # variable = "oxidised" + unit = "kO" + data = gas.read_all() + data = data.oxidising / 1000 + display_text(variables[mode], data, unit) + + if mode == 5: + # variable = "reduced" + unit = "kO" + data = gas.read_all() + data = data.reducing / 1000 + display_text(variables[mode], data, unit) + + if mode == 6: + # variable = "nh3" + unit = "kO" + data = gas.read_all() + data = data.nh3 / 1000 + display_text(variables[mode], data, unit) + + if mode == 7: + # variable = "pm1" + unit = "ug/m3" + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(1.0)) + display_text(variables[mode], data, unit) + + if mode == 8: + # variable = "pm25" + unit = "ug/m3" + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(2.5)) + display_text(variables[mode], data, unit) + + if mode == 9: + # variable = "pm10" + unit = "ug/m3" + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(10)) + display_text(variables[mode], data, unit) + if mode == 10: + # Everything on one screen + cpu_temp = get_cpu_temperature() + # Smooth out with some averaging to decrease jitter + cpu_temps = cpu_temps[1:] + [cpu_temp] + avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps)) + raw_temp = bme280.get_temperature() + raw_data = raw_temp - ((avg_cpu_temp - raw_temp) / factor) + save_data(0, raw_data) + display_everything() + raw_data = bme280.get_pressure() + save_data(1, raw_data) + display_everything() + raw_data = bme280.get_humidity() + save_data(2, raw_data) + if proximity < 10: + raw_data = ltr559.get_lux() + else: + raw_data = 1 + save_data(3, raw_data) + display_everything() + gas_data = gas.read_all() + save_data(4, gas_data.oxidising / 1000) + save_data(5, gas_data.reducing / 1000) + save_data(6, gas_data.nh3 / 1000) + display_everything() + pms_data = None + try: + pms_data = pms5003.read() + except pmsReadTimeoutError: + logging.warn("Failed to read PMS5003") + else: + save_data(7, float(pms_data.pm_ug_per_m3(1.0))) + save_data(8, float(pms_data.pm_ug_per_m3(2.5))) + save_data(9, float(pms_data.pm_ug_per_m3(10))) + display_everything() + + # Exit cleanly + except KeyboardInterrupt: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/examples/compensated-temperature.py b/examples/compensated-temperature.py new file mode 100644 index 0000000..b648f57 --- /dev/null +++ b/examples/compensated-temperature.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 + +import time +from bme280 import BME280 + +try: + from smbus2 import SMBus +except ImportError: + from smbus import SMBus + +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""compensated-temperature.py - Use the CPU temperature +to compensate temperature readings from the BME280 sensor. +Method adapted from Initial State's Enviro pHAT review: +https://medium.com/@InitialState/tutorial-review-enviro-phat-for-raspberry-pi-4cd6d8c63441 + +Press Ctrl+C to exit! + +""") + +bus = SMBus(1) +bme280 = BME280(i2c_dev=bus) + + +# Get the temperature of the CPU for compensation +def get_cpu_temperature(): + with open("/sys/class/thermal/thermal_zone0/temp", "r") as f: + temp = f.read() + temp = int(temp) / 1000.0 + return temp + + +# Tuning factor for compensation. Decrease this number to adjust the +# temperature down, and increase to adjust up +factor = 2.25 + +cpu_temps = [get_cpu_temperature()] * 5 + +while True: + cpu_temp = get_cpu_temperature() + # Smooth out with some averaging to decrease jitter + cpu_temps = cpu_temps[1:] + [cpu_temp] + avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps)) + raw_temp = bme280.get_temperature() + comp_temp = raw_temp - ((avg_cpu_temp - raw_temp) / factor) + logging.info("Compensated temperature: {:05.2f} *C".format(comp_temp)) + time.sleep(1.0) diff --git a/examples/gas.py b/examples/gas.py new file mode 100644 index 0000000..5d72cb9 --- /dev/null +++ b/examples/gas.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 + +import time +from enviroplus import gas +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""gas.py - Print readings from the MICS6814 Gas sensor. + +Press Ctrl+C to exit! + +""") + +try: + while True: + readings = gas.read_all() + logging.info(readings) + time.sleep(1.0) +except KeyboardInterrupt: + pass diff --git a/examples/icons/bulb-bright.png b/examples/icons/bulb-bright.png new file mode 100644 index 0000000..5697a81 Binary files /dev/null and b/examples/icons/bulb-bright.png differ diff --git a/examples/icons/bulb-dark.png b/examples/icons/bulb-dark.png new file mode 100644 index 0000000..a91e24b Binary files /dev/null and b/examples/icons/bulb-dark.png differ diff --git a/examples/icons/bulb-dim.png b/examples/icons/bulb-dim.png new file mode 100644 index 0000000..a91e24b Binary files /dev/null and b/examples/icons/bulb-dim.png differ diff --git a/examples/icons/bulb-light.png b/examples/icons/bulb-light.png new file mode 100644 index 0000000..f8fb791 Binary files /dev/null and b/examples/icons/bulb-light.png differ diff --git a/examples/icons/humidity-bad.png b/examples/icons/humidity-bad.png new file mode 100644 index 0000000..5a7201c Binary files /dev/null and b/examples/icons/humidity-bad.png differ diff --git a/examples/icons/humidity-good.png b/examples/icons/humidity-good.png new file mode 100644 index 0000000..ba450db Binary files /dev/null and b/examples/icons/humidity-good.png differ diff --git a/examples/icons/humidity.png b/examples/icons/humidity.png new file mode 100644 index 0000000..5a7201c Binary files /dev/null and b/examples/icons/humidity.png differ diff --git a/examples/icons/temperature.png b/examples/icons/temperature.png new file mode 100644 index 0000000..54a826f Binary files /dev/null and b/examples/icons/temperature.png differ diff --git a/examples/icons/weather-change.png b/examples/icons/weather-change.png new file mode 100644 index 0000000..21215b7 Binary files /dev/null and b/examples/icons/weather-change.png differ diff --git a/examples/icons/weather-dry.png b/examples/icons/weather-dry.png new file mode 100644 index 0000000..2302926 Binary files /dev/null and b/examples/icons/weather-dry.png differ diff --git a/examples/icons/weather-fair.png b/examples/icons/weather-fair.png new file mode 100644 index 0000000..2302926 Binary files /dev/null and b/examples/icons/weather-fair.png differ diff --git a/examples/icons/weather-rain.png b/examples/icons/weather-rain.png new file mode 100644 index 0000000..a7dea2f Binary files /dev/null and b/examples/icons/weather-rain.png differ diff --git a/examples/icons/weather-storm.png b/examples/icons/weather-storm.png new file mode 100644 index 0000000..2017245 Binary files /dev/null and b/examples/icons/weather-storm.png differ diff --git a/examples/lcd.py b/examples/lcd.py new file mode 100644 index 0000000..10413b9 --- /dev/null +++ b/examples/lcd.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +import ST7735 +from PIL import Image, ImageDraw, ImageFont +from fonts.ttf import RobotoMedium as UserFont +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""lcd.py - Hello, World! example on the 0.96" LCD. + +Press Ctrl+C to exit! + +""") + +# Create LCD class instance. +disp = ST7735.ST7735( + port=0, + cs=1, + dc=9, + backlight=12, + rotation=270, + spi_speed_hz=10000000 +) + +# Initialize display. +disp.begin() + +# Width and height to calculate text position. +WIDTH = disp.width +HEIGHT = disp.height + +# New canvas to draw on. +img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +draw = ImageDraw.Draw(img) + +# Text settings. +font_size = 25 +font = ImageFont.truetype(UserFont, font_size) +text_colour = (255, 255, 255) +back_colour = (0, 170, 170) + +message = "Hello, World!" +size_x, size_y = draw.textsize(message, font) + +# Calculate text position +x = (WIDTH - size_x) / 2 +y = (HEIGHT / 2) - (size_y / 2) + +# Draw background rectangle and write text. +draw.rectangle((0, 0, 160, 80), back_colour) +draw.text((x, y), message, font=font, fill=text_colour) +disp.display(img) + +# Keep running. +try: + while True: + pass + +# Turn off backlight on control-c +except KeyboardInterrupt: + disp.set_backlight(0) diff --git a/examples/light.py b/examples/light.py new file mode 100644 index 0000000..db61e6a --- /dev/null +++ b/examples/light.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 + +import time +import logging +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""light.py - Print readings from the LTR559 Light & Proximity sensor. + +Press Ctrl+C to exit! + +""") + +try: + while True: + lux = ltr559.get_lux() + prox = ltr559.get_proximity() + logging.info("""Light: {:05.02f} Lux +Proximity: {:05.02f} +""".format(lux, prox)) + time.sleep(1.0) +except KeyboardInterrupt: + pass diff --git a/examples/luftdaten.py b/examples/luftdaten.py new file mode 100644 index 0000000..84f1117 --- /dev/null +++ b/examples/luftdaten.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 + +import requests +import ST7735 +import time +from bme280 import BME280 +from pms5003 import PMS5003, ReadTimeoutError +from subprocess import PIPE, Popen, check_output +from PIL import Image, ImageDraw, ImageFont +from fonts.ttf import RobotoMedium as UserFont + +try: + from smbus2 import SMBus +except ImportError: + from smbus import SMBus + +print("""luftdaten.py - Reads temperature, pressure, humidity, +PM2.5, and PM10 from Enviro plus and sends data to Luftdaten, +the citizen science air quality project. + +Note: you'll need to register with Luftdaten at: +https://meine.luftdaten.info/ and enter your Raspberry Pi +serial number that's displayed on the Enviro plus LCD along +with the other details before the data appears on the +Luftdaten map. + +Press Ctrl+C to exit! + +""") + +bus = SMBus(1) + +# Create BME280 instance +bme280 = BME280(i2c_dev=bus) + +# Create LCD instance +disp = ST7735.ST7735( + port=0, + cs=1, + dc=9, + backlight=12, + rotation=270, + spi_speed_hz=10000000 +) + +# Initialize display +disp.begin() + +# Create PMS5003 instance +pms5003 = PMS5003() + + +# Read values from BME280 and PMS5003 and return as dict +def read_values(): + values = {} + cpu_temp = get_cpu_temperature() + raw_temp = bme280.get_temperature() + comp_temp = raw_temp - ((cpu_temp - raw_temp) / comp_factor) + values["temperature"] = "{:.2f}".format(comp_temp) + values["pressure"] = "{:.2f}".format(bme280.get_pressure() * 100) + values["humidity"] = "{:.2f}".format(bme280.get_humidity()) + try: + pm_values = pms5003.read() + values["P2"] = str(pm_values.pm_ug_per_m3(2.5)) + values["P1"] = str(pm_values.pm_ug_per_m3(10)) + except ReadTimeoutError: + pms5003.reset() + pm_values = pms5003.read() + values["P2"] = str(pm_values.pm_ug_per_m3(2.5)) + values["P1"] = str(pm_values.pm_ug_per_m3(10)) + return values + + +# Get CPU temperature to use for compensation +def get_cpu_temperature(): + process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE, universal_newlines=True) + output, _error = process.communicate() + return float(output[output.index('=') + 1:output.rindex("'")]) + + +# Get Raspberry Pi serial number to use as ID +def get_serial_number(): + with open('/proc/cpuinfo', 'r') as f: + for line in f: + if line[0:6] == 'Serial': + return line.split(":")[1].strip() + + +# Check for Wi-Fi connection +def check_wifi(): + if check_output(['hostname', '-I']): + return True + else: + return False + + +# Display Raspberry Pi serial and Wi-Fi status on LCD +def display_status(): + wifi_status = "connected" if check_wifi() else "disconnected" + text_colour = (255, 255, 255) + back_colour = (0, 170, 170) if check_wifi() else (85, 15, 15) + id = get_serial_number() + message = "{}\nWi-Fi: {}".format(id, wifi_status) + img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) + draw = ImageDraw.Draw(img) + size_x, size_y = draw.textsize(message, font) + x = (WIDTH - size_x) / 2 + y = (HEIGHT / 2) - (size_y / 2) + draw.rectangle((0, 0, 160, 80), back_colour) + draw.text((x, y), message, font=font, fill=text_colour) + disp.display(img) + + +def send_to_luftdaten(values, id): + pm_values = dict(i for i in values.items() if i[0].startswith("P")) + temp_values = dict(i for i in values.items() if not i[0].startswith("P")) + + pm_values_json = [{"value_type": key, "value": val} for key, val in pm_values.items()] + temp_values_json = [{"value_type": key, "value": val} for key, val in temp_values.items()] + + resp_1 = requests.post( + "https://api.luftdaten.info/v1/push-sensor-data/", + json={ + "software_version": "enviro-plus 0.0.1", + "sensordatavalues": pm_values_json + }, + headers={ + "X-PIN": "1", + "X-Sensor": id, + "Content-Type": "application/json", + "cache-control": "no-cache" + } + ) + + resp_2 = requests.post( + "https://api.luftdaten.info/v1/push-sensor-data/", + json={ + "software_version": "enviro-plus 0.0.1", + "sensordatavalues": temp_values_json + }, + headers={ + "X-PIN": "11", + "X-Sensor": id, + "Content-Type": "application/json", + "cache-control": "no-cache" + } + ) + + if resp_1.ok and resp_2.ok: + return True + else: + return False + + +# Compensation factor for temperature +comp_factor = 2.25 + +# Raspberry Pi ID to send to Luftdaten +id = "raspi-" + get_serial_number() + +# Width and height to calculate text position +WIDTH = disp.width +HEIGHT = disp.height + +# Text settings +font_size = 16 +font = ImageFont.truetype(UserFont, font_size) + +# Display Raspberry Pi serial and Wi-Fi status +print("Raspberry Pi serial: {}".format(get_serial_number())) +print("Wi-Fi: {}\n".format("connected" if check_wifi() else "disconnected")) + +time_since_update = 0 +update_time = time.time() + +# Main loop to read data, display, and send to Luftdaten +while True: + try: + time_since_update = time.time() - update_time + values = read_values() + print(values) + if time_since_update > 145: + resp = send_to_luftdaten(values, id) + update_time = time.time() + print("Response: {}\n".format("ok" if resp else "failed")) + display_status() + except Exception as e: + print(e) diff --git a/examples/noise-amps-at-freqs.py b/examples/noise-amps-at-freqs.py new file mode 100644 index 0000000..4c14c58 --- /dev/null +++ b/examples/noise-amps-at-freqs.py @@ -0,0 +1,44 @@ +import ST7735 +from PIL import Image, ImageDraw +from enviroplus.noise import Noise + +print("""noise-amps-at-freqs.py - Measure amplitude from specific frequency bins + +This example retrieves the median amplitude from 3 user-specified frequency ranges and plots them in Blue, Green and Red on the Enviro+ display. + +As you play a continuous rising tone on your phone, you should notice peaks that correspond to the frequency entering each range. + +Press Ctrl+C to exit! + +""") + +noise = Noise() + +disp = ST7735.ST7735( + port=0, + cs=ST7735.BG_SPI_CS_FRONT, + dc=9, + backlight=12, + rotation=90) + +disp.begin() + +img = Image.new('RGB', (disp.width, disp.height), color=(0, 0, 0)) +draw = ImageDraw.Draw(img) + + +while True: + amps = noise.get_amplitudes_at_frequency_ranges([ + (100, 200), + (500, 600), + (1000, 1200) + ]) + amps = [n * 32 for n in amps] + img2 = img.copy() + draw.rectangle((0, 0, disp.width, disp.height), (0, 0, 0)) + img.paste(img2, (1, 0)) + draw.line((0, 0, 0, amps[0]), fill=(0, 0, 255)) + draw.line((0, 0, 0, amps[1]), fill=(0, 255, 0)) + draw.line((0, 0, 0, amps[2]), fill=(255, 0, 0)) + + disp.display(img) diff --git a/examples/noise-profile.py b/examples/noise-profile.py new file mode 100644 index 0000000..4084439 --- /dev/null +++ b/examples/noise-profile.py @@ -0,0 +1,40 @@ +import ST7735 +from PIL import Image, ImageDraw +from enviroplus.noise import Noise + +print("""noise-profile.py - Get a simple noise profile. + +This example grabs a basic 3-bin noise profile of low, medium and high frequency noise, plotting the noise characteristics as coloured bars. + +Press Ctrl+C to exit! + +""") + +noise = Noise() + +disp = ST7735.ST7735( + port=0, + cs=ST7735.BG_SPI_CS_FRONT, + dc=9, + backlight=12, + rotation=90) + +disp.begin() + +img = Image.new('RGB', (disp.width, disp.height), color=(0, 0, 0)) +draw = ImageDraw.Draw(img) + + +while True: + low, mid, high, amp = noise.get_noise_profile() + low *= 128 + mid *= 128 + high *= 128 + amp *= 64 + + img2 = img.copy() + draw.rectangle((0, 0, disp.width, disp.height), (0, 0, 0)) + img.paste(img2, (1, 0)) + draw.line((0, 0, 0, amp), fill=(int(low), int(mid), int(high))) + + disp.display(img) diff --git a/examples/particulates.py b/examples/particulates.py new file mode 100644 index 0000000..04a4950 --- /dev/null +++ b/examples/particulates.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 + +import time +from pms5003 import PMS5003, ReadTimeoutError +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""particulates.py - Print readings from the PMS5003 particulate sensor. + +Press Ctrl+C to exit! + +""") + +pms5003 = PMS5003() +time.sleep(1.0) + +try: + while True: + try: + readings = pms5003.read() + logging.info(readings) + except ReadTimeoutError: + pms5003 = PMS5003() +except KeyboardInterrupt: + pass diff --git a/examples/weather-and-light.py b/examples/weather-and-light.py new file mode 100644 index 0000000..bccf7cc --- /dev/null +++ b/examples/weather-and-light.py @@ -0,0 +1,425 @@ +#!/usr/bin/env python3 + +import os +import time +import numpy +import colorsys +from PIL import Image, ImageDraw, ImageFont, ImageFilter +from fonts.ttf import RobotoMedium as UserFont + +import ST7735 +from bme280 import BME280 +from ltr559 import LTR559 + +import pytz +from pytz import timezone +from astral.geocoder import database, lookup +from astral.sun import sun +from datetime import datetime, timedelta + +try: + from smbus2 import SMBus +except ImportError: + from smbus import SMBus + + +def calculate_y_pos(x, centre): + """Calculates the y-coordinate on a parabolic curve, given x.""" + centre = 80 + y = 1 / centre * (x - centre) ** 2 + + return int(y) + + +def circle_coordinates(x, y, radius): + """Calculates the bounds of a circle, given centre and radius.""" + + x1 = x - radius # Left + x2 = x + radius # Right + y1 = y - radius # Bottom + y2 = y + radius # Top + + return (x1, y1, x2, y2) + + +def map_colour(x, centre, start_hue, end_hue, day): + """Given an x coordinate and a centre point, a start and end hue (in degrees), + and a Boolean for day or night (day is True, night False), calculate a colour + hue representing the 'colour' of that time of day.""" + + start_hue = start_hue / 360 # Rescale to between 0 and 1 + end_hue = end_hue / 360 + + sat = 1.0 + + # Dim the brightness as you move from the centre to the edges + val = 1 - (abs(centre - x) / (2 * centre)) + + # Ramp up towards centre, then back down + if x > centre: + x = (2 * centre) - x + + # Calculate the hue + hue = start_hue + ((x / centre) * (end_hue - start_hue)) + + # At night, move towards purple/blue hues and reverse dimming + if not day: + hue = 1 - hue + val = 1 - val + + r, g, b = [int(c * 255) for c in colorsys.hsv_to_rgb(hue, sat, val)] + + return (r, g, b) + + +def x_from_sun_moon_time(progress, period, x_range): + """Recalculate/rescale an amount of progress through a time period.""" + + x = int((progress / period) * x_range) + + return x + + +def sun_moon_time(city_name, time_zone): + """Calculate the progress through the current sun/moon period (i.e day or + night) from the last sunrise or sunset, given a datetime object 't'.""" + + city = lookup(city_name, database()) + + # Datetime objects for yesterday, today, tomorrow + utc = pytz.utc + utc_dt = datetime.now(tz=utc) + local_dt = utc_dt.astimezone(pytz.timezone(time_zone)) + today = local_dt.date() + yesterday = today - timedelta(1) + tomorrow = today + timedelta(1) + + # Sun objects for yesterday, today, tomorrow + sun_yesterday = sun(city.observer, date=yesterday) + sun_today = sun(city.observer, date=today) + sun_tomorrow = sun(city.observer, date=tomorrow) + + # Work out sunset yesterday, sunrise/sunset today, and sunrise tomorrow + sunset_yesterday = sun_yesterday["sunset"] + sunrise_today = sun_today["sunrise"] + sunset_today = sun_today["sunset"] + sunrise_tomorrow = sun_tomorrow["sunrise"] + + # Work out lengths of day or night period and progress through period + if sunrise_today < local_dt < sunset_today: + day = True + period = sunset_today - sunrise_today + # mid = sunrise_today + (period / 2) + progress = local_dt - sunrise_today + + elif local_dt > sunset_today: + day = False + period = sunrise_tomorrow - sunset_today + # mid = sunset_today + (period / 2) + progress = local_dt - sunset_today + + else: + day = False + period = sunrise_today - sunset_yesterday + # mid = sunset_yesterday + (period / 2) + progress = local_dt - sunset_yesterday + + # Convert time deltas to seconds + progress = progress.total_seconds() + period = period.total_seconds() + + return (progress, period, day, local_dt) + + +def draw_background(progress, period, day): + """Given an amount of progress through the day or night, draw the + background colour and overlay a blurred sun/moon.""" + + # x-coordinate for sun/moon + x = x_from_sun_moon_time(progress, period, WIDTH) + + # If it's day, then move right to left + if day: + x = WIDTH - x + + # Calculate position on sun/moon's curve + centre = WIDTH / 2 + y = calculate_y_pos(x, centre) + + # Background colour + background = map_colour(x, 80, mid_hue, day_hue, day) + + # New image for background colour + img = Image.new('RGBA', (WIDTH, HEIGHT), color=background) + # draw = ImageDraw.Draw(img) + + # New image for sun/moon overlay + overlay = Image.new('RGBA', (WIDTH, HEIGHT), color=(0, 0, 0, 0)) + overlay_draw = ImageDraw.Draw(overlay) + + # Draw the sun/moon + circle = circle_coordinates(x, y, sun_radius) + overlay_draw.ellipse(circle, fill=(200, 200, 50, opacity)) + + # Overlay the sun/moon on the background as an alpha matte + composite = Image.alpha_composite(img, overlay).filter(ImageFilter.GaussianBlur(radius=blur)) + + return composite + + +def overlay_text(img, position, text, font, align_right=False, rectangle=False): + draw = ImageDraw.Draw(img) + w, h = font.getsize(text) + if align_right: + x, y = position + x -= w + position = (x, y) + if rectangle: + x += 1 + y += 1 + position = (x, y) + border = 1 + rect = (x - border, y, x + w, y + h + border) + rect_img = Image.new('RGBA', (WIDTH, HEIGHT), color=(0, 0, 0, 0)) + rect_draw = ImageDraw.Draw(rect_img) + rect_draw.rectangle(rect, (255, 255, 255)) + rect_draw.text(position, text, font=font, fill=(0, 0, 0, 0)) + img = Image.alpha_composite(img, rect_img) + else: + draw.text(position, text, font=font, fill=(255, 255, 255)) + return img + + +def get_cpu_temperature(): + with open("/sys/class/thermal/thermal_zone0/temp", "r") as f: + temp = f.read() + temp = int(temp) / 1000.0 + return temp + + +def correct_humidity(humidity, temperature, corr_temperature): + dewpoint = temperature - ((100 - humidity) / 5) + corr_humidity = 100 - (5 * (corr_temperature - dewpoint)) + return min(100, corr_humidity) + + +def analyse_pressure(pressure, t): + global time_vals, pressure_vals, trend + if len(pressure_vals) > num_vals: + pressure_vals = pressure_vals[1:] + [pressure] + time_vals = time_vals[1:] + [t] + + # Calculate line of best fit + line = numpy.polyfit(time_vals, pressure_vals, 1, full=True) + + # Calculate slope, variance, and confidence + slope = line[0][0] + intercept = line[0][1] + variance = numpy.var(pressure_vals) + residuals = numpy.var([(slope * x + intercept - y) for x, y in zip(time_vals, pressure_vals)]) + r_squared = 1 - residuals / variance + + # Calculate change in pressure per hour + change_per_hour = slope * 60 * 60 + # variance_per_hour = variance * 60 * 60 + + mean_pressure = numpy.mean(pressure_vals) + + # Calculate trend + if r_squared > 0.5: + if change_per_hour > 0.5: + trend = ">" + elif change_per_hour < -0.5: + trend = "<" + elif -0.5 <= change_per_hour <= 0.5: + trend = "-" + + if trend != "-": + if abs(change_per_hour) > 3: + trend *= 2 + else: + pressure_vals.append(pressure) + time_vals.append(t) + mean_pressure = numpy.mean(pressure_vals) + change_per_hour = 0 + trend = "-" + + # time.sleep(interval) + return (mean_pressure, change_per_hour, trend) + + +def describe_pressure(pressure): + """Convert pressure into barometer-type description.""" + if pressure < 970: + description = "storm" + elif 970 <= pressure < 990: + description = "rain" + elif 990 <= pressure < 1010: + description = "change" + elif 1010 <= pressure < 1030: + description = "fair" + elif pressure >= 1030: + description = "dry" + else: + description = "" + return description + + +def describe_humidity(humidity): + """Convert relative humidity into good/bad description.""" + if 40 < humidity < 60: + description = "good" + else: + description = "bad" + return description + + +def describe_light(light): + """Convert light level in lux to descriptive value.""" + if light < 50: + description = "dark" + elif 50 <= light < 100: + description = "dim" + elif 100 <= light < 500: + description = "light" + elif light >= 500: + description = "bright" + return description + + +# Initialise the LCD +disp = ST7735.ST7735( + port=0, + cs=1, + dc=9, + backlight=12, + rotation=270, + spi_speed_hz=10000000 +) + +disp.begin() + +WIDTH = disp.width +HEIGHT = disp.height + +# The city and timezone that you want to display. +city_name = "Sheffield" +time_zone = "Europe/London" + +# Values that alter the look of the background +blur = 50 +opacity = 125 + +mid_hue = 0 +day_hue = 25 + +sun_radius = 50 + +# Fonts +font_sm = ImageFont.truetype(UserFont, 12) +font_lg = ImageFont.truetype(UserFont, 14) + +# Margins +margin = 3 + + +# Set up BME280 weather sensor +bus = SMBus(1) +bme280 = BME280(i2c_dev=bus) + +min_temp = None +max_temp = None + +factor = 2.25 +cpu_temps = [get_cpu_temperature()] * 5 + +# Set up light sensor +ltr559 = LTR559() + +# Pressure variables +pressure_vals = [] +time_vals = [] +num_vals = 1000 +interval = 1 +trend = "-" + +# Keep track of time elapsed +start_time = time.time() + +while True: + path = os.path.dirname(os.path.realpath(__file__)) + progress, period, day, local_dt = sun_moon_time(city_name, time_zone) + background = draw_background(progress, period, day) + + # Time. + time_elapsed = time.time() - start_time + date_string = local_dt.strftime("%d %b %y").lstrip('0') + time_string = local_dt.strftime("%H:%M") + img = overlay_text(background, (0 + margin, 0 + margin), time_string, font_lg) + img = overlay_text(img, (WIDTH - margin, 0 + margin), date_string, font_lg, align_right=True) + + # Temperature + temperature = bme280.get_temperature() + + # Corrected temperature + cpu_temp = get_cpu_temperature() + cpu_temps = cpu_temps[1:] + [cpu_temp] + avg_cpu_temp = sum(cpu_temps) / float(len(cpu_temps)) + corr_temperature = temperature - ((avg_cpu_temp - temperature) / factor) + + if time_elapsed > 30: + if min_temp is not None and max_temp is not None: + if corr_temperature < min_temp: + min_temp = corr_temperature + elif corr_temperature > max_temp: + max_temp = corr_temperature + else: + min_temp = corr_temperature + max_temp = corr_temperature + + temp_string = f"{corr_temperature:.0f}°C" + img = overlay_text(img, (68, 18), temp_string, font_lg, align_right=True) + spacing = font_lg.getsize(temp_string)[1] + 1 + if min_temp is not None and max_temp is not None: + range_string = f"{min_temp:.0f}-{max_temp:.0f}" + else: + range_string = "------" + img = overlay_text(img, (68, 18 + spacing), range_string, font_sm, align_right=True, rectangle=True) + temp_icon = Image.open(f"{path}/icons/temperature.png") + img.paste(temp_icon, (margin, 18), mask=temp_icon) + + # Humidity + humidity = bme280.get_humidity() + corr_humidity = correct_humidity(humidity, temperature, corr_temperature) + humidity_string = f"{corr_humidity:.0f}%" + img = overlay_text(img, (68, 48), humidity_string, font_lg, align_right=True) + spacing = font_lg.getsize(humidity_string)[1] + 1 + humidity_desc = describe_humidity(corr_humidity).upper() + img = overlay_text(img, (68, 48 + spacing), humidity_desc, font_sm, align_right=True, rectangle=True) + humidity_icon = Image.open(f"{path}/icons/humidity-{humidity_desc.lower()}.png") + img.paste(humidity_icon, (margin, 48), mask=humidity_icon) + + # Light + light = ltr559.get_lux() + light_string = f"{int(light):,}" + img = overlay_text(img, (WIDTH - margin, 18), light_string, font_lg, align_right=True) + spacing = font_lg.getsize(light_string.replace(",", ""))[1] + 1 + light_desc = describe_light(light).upper() + img = overlay_text(img, (WIDTH - margin - 1, 18 + spacing), light_desc, font_sm, align_right=True, rectangle=True) + light_icon = Image.open(f"{path}/icons/bulb-{light_desc.lower()}.png") + img.paste(humidity_icon, (80, 18), mask=light_icon) + + # Pressure + pressure = bme280.get_pressure() + t = time.time() + mean_pressure, change_per_hour, trend = analyse_pressure(pressure, t) + pressure_string = f"{int(mean_pressure):,} {trend}" + img = overlay_text(img, (WIDTH - margin, 48), pressure_string, font_lg, align_right=True) + pressure_desc = describe_pressure(mean_pressure).upper() + spacing = font_lg.getsize(pressure_string.replace(",", ""))[1] + 1 + img = overlay_text(img, (WIDTH - margin - 1, 48 + spacing), pressure_desc, font_sm, align_right=True, rectangle=True) + pressure_icon = Image.open(f"{path}/icons/weather-{pressure_desc.lower()}.png") + img.paste(pressure_icon, (80, 48), mask=pressure_icon) + + # Display image + disp.display(img) diff --git a/examples/weather.py b/examples/weather.py new file mode 100644 index 0000000..66f18e0 --- /dev/null +++ b/examples/weather.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + +import time +from bme280 import BME280 + +try: + from smbus2 import SMBus +except ImportError: + from smbus import SMBus + +import logging + +logging.basicConfig( + format='%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s', + level=logging.INFO, + datefmt='%Y-%m-%d %H:%M:%S') + +logging.info("""weather.py - Print readings from the BME280 weather sensor. + +Press Ctrl+C to exit! + +""") + +bus = SMBus(1) +bme280 = BME280(i2c_dev=bus) + +while True: + temperature = bme280.get_temperature() + pressure = bme280.get_pressure() + humidity = bme280.get_humidity() + logging.info("""Temperature: {:05.2f} *C +Pressure: {:05.2f} hPa +Relative humidity: {:05.2f} % +""".format(temperature, pressure, humidity)) + time.sleep(1) diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..3e5b898 --- /dev/null +++ b/install.sh @@ -0,0 +1,193 @@ +#!/bin/bash + +CONFIG=/boot/config.txt +DATESTAMP=`date "+%Y-%M-%d-%H-%M-%S"` +CONFIG_BACKUP=false +APT_HAS_UPDATED=false +USER_HOME=/home/$SUDO_USER +RESOURCES_TOP_DIR=$USER_HOME/Pimoroni +WD=`pwd` + +user_check() { + if [ $(id -u) -ne 0 ]; then + printf "Script must be run as root. Try 'sudo ./install.sh'\n" + exit 1 + fi +} + +confirm() { + if [ "$FORCE" == '-y' ]; then + true + else + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi + fi +} + +prompt() { + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi +} + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} + +function do_config_backup { + if [ ! $CONFIG_BACKUP == true ]; then + CONFIG_BACKUP=true + FILENAME="config.preinstall-$LIBRARY_NAME-$DATESTAMP.txt" + inform "Backing up $CONFIG to /boot/$FILENAME\n" + cp $CONFIG /boot/$FILENAME + mkdir -p $RESOURCES_TOP_DIR/config-backups/ + cp $CONFIG $RESOURCES_TOP_DIR/config-backups/$FILENAME + if [ -f "$UNINSTALLER" ]; then + echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG" >> $UNINSTALLER + fi + fi +} + +function apt_pkg_install { + PACKAGES=() + PACKAGES_IN=("$@") + for ((i = 0; i < ${#PACKAGES_IN[@]}; i++)); do + PACKAGE="${PACKAGES_IN[$i]}" + printf "Checking for $PACKAGE\n" + dpkg -L $PACKAGE > /dev/null 2>&1 + if [ "$?" == "1" ]; then + PACKAGES+=("$PACKAGE") + fi + done + PACKAGES="${PACKAGES[@]}" + if ! [ "$PACKAGES" == "" ]; then + echo "Installing missing packages: $PACKAGES" + if [ ! $APT_HAS_UPDATED ]; then + apt update + APT_HAS_UPDATED=true + fi + apt install -y $PACKAGES + if [ -f "$UNINSTALLER" ]; then + echo "apt uninstall -y $PACKAGES" + fi + fi +} + +user_check + +apt_pkg_install python-configparser + +CONFIG_VARS=`python - < $UNINSTALLER +printf "It's recommended you run these steps manually.\n" +printf "If you want to run the full script, open it in\n" +printf "an editor and remove 'exit 1' from below.\n" +exit 1 +EOF + +printf "$LIBRARY_NAME $LIBRARY_VERSION Python Library: Installer\n\n" + +cd library + +printf "Installing for Python 2..\n" +apt_pkg_install "${PY2_DEPS[@]}" +python setup.py install > /dev/null +if [ $? -eq 0 ]; then + success "Done!\n" + echo "pip uninstall $LIBRARY_NAME" >> $UNINSTALLER +fi + +if [ -f "/usr/bin/python3" ]; then + printf "Installing for Python 3..\n" + apt_pkg_install "${PY3_DEPS[@]}" + python3 setup.py install > /dev/null + if [ $? -eq 0 ]; then + success "Done!\n" + echo "pip3 uninstall $LIBRARY_NAME" >> $UNINSTALLER + fi +fi + +cd $WD + +for ((i = 0; i < ${#SETUP_CMDS[@]}; i++)); do + CMD="${SETUP_CMDS[$i]}" + # Attempt to catch anything that touches /boot/config.txt and trigger a backup + if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG"* ]] || [[ "$CMD" == *"\$CONFIG"* ]]; then + do_config_backup + fi + eval $CMD +done + +for ((i = 0; i < ${#CONFIG_TXT[@]}; i++)); do + CONFIG_LINE="${CONFIG_TXT[$i]}" + if ! [ "$CONFIG_LINE" == "" ]; then + do_config_backup + inform "Adding $CONFIG_LINE to $CONFIG\n" + sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG + if ! grep -q "^$CONFIG_LINE" $CONFIG; then + printf "$CONFIG_LINE\n" >> $CONFIG + fi + fi +done + +if [ -d "examples" ]; then + if confirm "Would you like to copy examples to $RESOURCES_DIR?"; then + inform "Copying examples to $RESOURCES_DIR" + cp -r examples/ $RESOURCES_DIR + echo "rm -r $RESOURCES_DIR" >> $UNINSTALLER + fi +fi + +success "\nAll done!" +inform "If this is your first time installing you should reboot for hardware changes to take effect.\n" +inform "Find uninstall steps in $UNINSTALLER\n" diff --git a/library/.coveragerc b/library/.coveragerc new file mode 100644 index 0000000..48d2b51 --- /dev/null +++ b/library/.coveragerc @@ -0,0 +1,4 @@ +[run] +source = enviroplus +omit = + .tox/* diff --git a/library/CHANGELOG.txt b/library/CHANGELOG.txt new file mode 100644 index 0000000..81d4136 --- /dev/null +++ b/library/CHANGELOG.txt @@ -0,0 +1,16 @@ +0.0.3 +----- + +* Fix "self.noise_floor" bug in get_noise_profile + +0.0.2 +----- + +* Add support for extra ADC channel in Gas +* Handle breaking change in new ltr559 library +* Add Noise functionality + +0.0.1 +----- + +* Initial Release diff --git a/library/LICENSE.txt b/library/LICENSE.txt new file mode 100644 index 0000000..aed751a --- /dev/null +++ b/library/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Pimoroni Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/library/MANIFEST.in b/library/MANIFEST.in new file mode 100644 index 0000000..43329d9 --- /dev/null +++ b/library/MANIFEST.in @@ -0,0 +1,5 @@ +include CHANGELOG.txt +include LICENSE.txt +include README.rst +include setup.py +recursive-include enviroplus *.py diff --git a/library/README.rst b/library/README.rst new file mode 100644 index 0000000..bd74b9d --- /dev/null +++ b/library/README.rst @@ -0,0 +1,93 @@ +Enviro+ +======= + +Designed for environmental monitoring, Enviro+ lets you measure air +quality (pollutant gases and particulates), temperature, pressure, +humidity, light, and noise level. Learn more - +https://shop.pimoroni.com/products/enviro-plus + +|Build Status| |Coverage Status| |PyPi Package| |Python Versions| + +Installing +========== + +You're best using the "One-line" install method if you want all of the +UART serial configuration for the PMS5003 particulate matter sensor to +run automatically. + +One-line (Installs from GitHub) +------------------------------- + +:: + + curl -sSL https://get.pimoroni.com/enviroplus | bash + +**Note** report issues with one-line installer here: +https://github.com/pimoroni/get + +Or... Install and configure dependencies from GitHub: +----------------------------------------------------- + +- ``git clone https://github.com/pimoroni/enviroplus-python`` +- ``cd enviroplus-python`` +- ``sudo ./install.sh`` + +**Note** Raspbian Lite users may first need to install git: +``sudo apt install git`` + +Or... Install from PyPi and configure manually: +----------------------------------------------- + +- Run ``sudo pip install enviroplus`` + +**Note** this wont perform any of the required configuration changes on +your Pi, you may additionally need to: + +- Enable i2c: ``raspi-config nonint do_i2c 0`` +- Enable SPI: ``raspi-config nonint do_spi 0`` + +And if you're using a PMS5003 sensor you will need to: + +- Enable serial: + ``raspi-config nonint set_config_var enable_uart 1 /boot/config.txt`` +- Disable serial terminal: ``sudo raspi-config nonint do_serial 1`` +- Add ``dtoverlay=pi3-miniuart-bt`` to your ``/boot/config.txt`` + +And install additional dependencies: + +:: + + sudo apt install python-numpy python-smbus python-pil python-setuptools + +Help & Support +-------------- + +- GPIO Pinout - https://pinout.xyz/pinout/enviro\_plus +- Support forums - http://forums.pimoroni.com/c/support +- Discord - https://discord.gg/hr93ByC + +.. |Build Status| image:: https://travis-ci.com/pimoroni/enviroplus-python.svg?branch=master + :target: https://travis-ci.com/pimoroni/enviroplus-python +.. |Coverage Status| image:: https://coveralls.io/repos/github/pimoroni/enviroplus-python/badge.svg?branch=master + :target: https://coveralls.io/github/pimoroni/enviroplus-python?branch=master +.. |PyPi Package| image:: https://img.shields.io/pypi/v/enviroplus.svg + :target: https://pypi.python.org/pypi/enviroplus +.. |Python Versions| image:: https://img.shields.io/pypi/pyversions/enviroplus.svg + :target: https://pypi.python.org/pypi/enviroplus + +0.0.3 +----- + +* Fix "self.noise_floor" bug in get_noise_profile + +0.0.2 +----- + +* Add support for extra ADC channel in Gas +* Handle breaking change in new ltr559 library +* Add Noise functionality + +0.0.1 +----- + +* Initial Release diff --git a/library/grow/__init__.py b/library/grow/__init__.py new file mode 100644 index 0000000..ffcc925 --- /dev/null +++ b/library/grow/__init__.py @@ -0,0 +1 @@ +__version__ = '0.0.3' diff --git a/library/grow/gas.py b/library/grow/gas.py new file mode 100644 index 0000000..584317b --- /dev/null +++ b/library/grow/gas.py @@ -0,0 +1,140 @@ +"""Read the MICS6814 via an ads1015 ADC""" + +import time +import atexit +import ads1015 +import RPi.GPIO as GPIO + +MICS6814_HEATER_PIN = 24 +MICS6814_GAIN = 6.144 + +ads1015.I2C_ADDRESS_DEFAULT = ads1015.I2C_ADDRESS_ALTERNATE +_is_setup = False +_adc_enabled = False +_adc_gain = 6.148 + + +class Mics6814Reading(object): + __slots__ = 'oxidising', 'reducing', 'nh3', 'adc' + + def __init__(self, ox, red, nh3, adc=None): + self.oxidising = ox + self.reducing = red + self.nh3 = nh3 + self.adc = adc + + def __repr__(self): + fmt = """Oxidising: {ox:05.02f} Ohms +Reducing: {red:05.02f} Ohms +NH3: {nh3:05.02f} Ohms""" + if self.adc is not None: + fmt += """ +ADC: {adc:05.02f} Volts +""" + return fmt.format( + ox=self.oxidising, + red=self.reducing, + nh3=self.nh3, + adc=self.adc) + + __str__ = __repr__ + + +def setup(): + global adc, _is_setup + if _is_setup: + return + _is_setup = True + + adc = ads1015.ADS1015(i2c_addr=0x49) + adc.set_mode('single') + adc.set_programmable_gain(MICS6814_GAIN) + adc.set_sample_rate(1600) + + GPIO.setwarnings(False) + GPIO.setmode(GPIO.BCM) + GPIO.setup(MICS6814_HEATER_PIN, GPIO.OUT) + GPIO.output(MICS6814_HEATER_PIN, 1) + atexit.register(cleanup) + + +def enable_adc(value=True): + """Enable reading from the additional ADC pin.""" + global _adc_enabled + _adc_enabled = value + + +def set_adc_gain(value): + """Set gain value for the additional ADC pin.""" + global _adc_gain + _adc_gain = value + + +def cleanup(): + GPIO.output(MICS6814_HEATER_PIN, 0) + + +def read_all(): + """Return gas resistence for oxidising, reducing and NH3""" + setup() + ox = adc.get_voltage('in0/gnd') + red = adc.get_voltage('in1/gnd') + nh3 = adc.get_voltage('in2/gnd') + + try: + ox = (ox * 56000) / (3.3 - ox) + except ZeroDivisionError: + ox = 0 + + try: + red = (red * 56000) / (3.3 - red) + except ZeroDivisionError: + red = 0 + + try: + nh3 = (nh3 * 56000) / (3.3 - nh3) + except ZeroDivisionError: + nh3 = 0 + + analog = None + + if _adc_enabled: + if _adc_gain == MICS6814_GAIN: + analog = adc.get_voltage('ref/gnd') + else: + adc.set_programmable_gain(_adc_gain) + time.sleep(0.05) + analog = adc.get_voltage('ref/gnd') + adc.set_programmable_gain(MICS6814_GAIN) + + return Mics6814Reading(ox, red, nh3, analog) + + +def read_oxidising(): + """Return gas resistance for oxidising gases. + + Eg chlorine, nitrous oxide + """ + setup() + return read_all().oxidising + + +def read_reducing(): + """Return gas resistance for reducing gases. + + Eg hydrogen, carbon monoxide + """ + setup() + return read_all().reducing + + +def read_nh3(): + """Return gas resistance for nh3/ammonia""" + setup() + return read_all().nh3 + + +def read_adc(): + """Return spare ADC channel value""" + setup() + return read_all().adc diff --git a/library/grow/noise.py b/library/grow/noise.py new file mode 100644 index 0000000..7b6d5e2 --- /dev/null +++ b/library/grow/noise.py @@ -0,0 +1,90 @@ +import sounddevice +import numpy + + +class Noise(): + def __init__(self, + sample_rate=16000, + duration=0.5): + """Noise measurement. + + :param sample_rate: Sample rate in Hz + :param duraton: Duration, in seconds, of noise sample capture + + """ + + self.duration = duration + self.sample_rate = sample_rate + + def get_amplitudes_at_frequency_ranges(self, ranges): + """Return the mean amplitude of frequencies in the given ranges. + + :param ranges: List of ranges including a start and end range + + """ + recording = self._record() + magnitude = numpy.abs(numpy.fft.rfft(recording[:, 0], n=self.sample_rate)) + result = [] + for r in ranges: + start, end = r + result.append(numpy.mean(magnitude[start:end])) + return result + + def get_amplitude_at_frequency_range(self, start, end): + """Return the mean amplitude of frequencies in the specified range. + + :param start: Start frequency (in Hz) + :param end: End frequency (in Hz) + + """ + n = self.sample_rate // 2 + if start > n or end > n: + raise ValueError("Maxmimum frequency is {}".format(n)) + + recording = self._record() + magnitude = numpy.abs(numpy.fft.rfft(recording[:, 0], n=self.sample_rate)) + return numpy.mean(magnitude[start:end]) + + def get_noise_profile(self, + noise_floor=100, + low=0.12, + mid=0.36, + high=None): + """Returns a noise charateristic profile. + + Bins all frequencies into 3 weighted groups expressed as a percentage of the total frequency range. + + :param noise_floor: "High-pass" frequency, exclude frequencies below this value + :param low: Percentage of frequency ranges to count in the low bin (as a float, 0.5 = 50%) + :param mid: Percentage of frequency ranges to count in the mid bin (as a float, 0.5 = 50%) + :param high: Optional percentage for high bin, effectively creates a "Low-pass" if total percentage is less than 100% + + """ + + if high is None: + high = 1.0 - low - mid + + recording = self._record() + magnitude = numpy.abs(numpy.fft.rfft(recording[:, 0], n=self.sample_rate)) + + sample_count = (self.sample_rate // 2) - noise_floor + + mid_start = noise_floor + int(sample_count * low) + high_start = mid_start + int(sample_count * mid) + noise_ceiling = high_start + int(sample_count * high) + + amp_low = numpy.mean(magnitude[noise_floor:mid_start]) + amp_mid = numpy.mean(magnitude[mid_start:high_start]) + amp_high = numpy.mean(magnitude[high_start:noise_ceiling]) + amp_total = (amp_low + amp_mid + amp_high) / 3.0 + + return amp_low, amp_mid, amp_high, amp_total + + def _record(self): + return sounddevice.rec( + int(self.duration * self.sample_rate), + samplerate=self.sample_rate, + blocking=True, + channels=1, + dtype='float64' + ) diff --git a/library/setup.cfg b/library/setup.cfg new file mode 100644 index 0000000..af306be --- /dev/null +++ b/library/setup.cfg @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +[metadata] +name = enviroplus +version = 0.0.3 +author = Philip Howard +author_email = phil@pimoroni.com +description = Enviro pHAT Plus environmental monitoring add-on for Raspberry Pi +long_description = file: README.rst +keywords = Raspberry Pi +url = https://www.pimoroni.com +project_urls = + GitHub=https://www.github.com/pimoroni/enviroplus-python +license = MIT +# This includes the license file(s) in the wheel. +# https://wheel.readthedocs.io/en/stable/user_guide.html#including-license-files-in-the-generated-wheel-file +license_files = LICENSE.txt +classifiers = + Development Status :: 4 - Beta + Operating System :: POSIX :: Linux + License :: OSI Approved :: MIT License + Intended Audience :: Developers + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Topic :: Software Development + Topic :: Software Development :: Libraries + Topic :: System :: Hardware + +[options] +packages = enviroplus +install_requires = + pimoroni-bme280 + pms5003 + ltr559 + st7735 + ads1015 + fonts + font-roboto + astral + pytz + sounddevice + +[flake8] +exclude = + .tox, + .eggs, + .git, + __pycache__, + build, + dist +ignore = + E501 + +[pimoroni] +py2deps = + python-pip + python-numpy + python-smbus + python-pil + python-spidev + python-rpi.gpio + libportaudio2 +py3deps = + python3-pip + python3-numpy + python3-smbus + python3-pil + python3-spidev + python3-rpi.gpio + libportaudio2 +configtxt = + dtoverlay=pi3-miniuart-bt + dtoverlay=adau7002-simple +commands = + printf "Setting up i2c and SPI..\n" + raspi-config nonint do_spi 0 + raspi-config nonint do_i2c 0 + printf "Setting up serial for PMS5003..\n" + raspi-config nonint do_serial 1 # Disable serial terminal over /dev/ttyAMA0 + raspi-config nonint set_config_var enable_uart 1 $CONFIG # Enable serial port diff --git a/library/setup.py b/library/setup.py new file mode 100644 index 0000000..40d6dbc --- /dev/null +++ b/library/setup.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2016 Pimoroni + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" + +from setuptools import setup, __version__ +from pkg_resources import parse_version + +minimum_version = parse_version('30.4.0') + +if parse_version(__version__) < minimum_version: + raise RuntimeError("Package setuptools must be at least version {}".format(minimum_version)) + +setup() diff --git a/library/tests/conftest.py b/library/tests/conftest.py new file mode 100644 index 0000000..c88c4af --- /dev/null +++ b/library/tests/conftest.py @@ -0,0 +1,90 @@ +"""Test configuration. +These allow the mocking of various Python modules +that might otherwise have runtime side-effects. +""" +import sys +import mock +import pytest +from i2cdevice import MockSMBus + + +class SMBusFakeDevice(MockSMBus): + def __init__(self, i2c_bus): + MockSMBus.__init__(self, i2c_bus) + self.regs[0x00:0x01] = 0x0f, 0x00 + + +@pytest.fixture(scope='function', autouse=True) +def cleanup(): + yield None + try: + del sys.modules['enviroplus'] + except KeyError: + pass + try: + del sys.modules['enviroplus.noise'] + except KeyError: + pass + try: + del sys.modules['enviroplus.gas'] + except KeyError: + pass + + +@pytest.fixture(scope='function', autouse=False) +def GPIO(): + """Mock RPi.GPIO module.""" + GPIO = mock.MagicMock() + # Fudge for Python < 37 (possibly earlier) + sys.modules['RPi'] = mock.Mock() + sys.modules['RPi'].GPIO = GPIO + sys.modules['RPi.GPIO'] = GPIO + yield GPIO + del sys.modules['RPi'] + del sys.modules['RPi.GPIO'] + + +@pytest.fixture(scope='function', autouse=False) +def spidev(): + """Mock spidev module.""" + spidev = mock.MagicMock() + sys.modules['spidev'] = spidev + yield spidev + del sys.modules['spidev'] + + +@pytest.fixture(scope='function', autouse=False) +def smbus(): + """Mock smbus module.""" + smbus = mock.MagicMock() + smbus.SMBus = SMBusFakeDevice + sys.modules['smbus'] = smbus + yield smbus + del sys.modules['smbus'] + + +@pytest.fixture(scope='function', autouse=False) +def atexit(): + """Mock atexit module.""" + atexit = mock.MagicMock() + sys.modules['atexit'] = atexit + yield atexit + del sys.modules['atexit'] + + +@pytest.fixture(scope='function', autouse=False) +def sounddevice(): + """Mock sounddevice module.""" + sounddevice = mock.MagicMock() + sys.modules['sounddevice'] = sounddevice + yield sounddevice + del sys.modules['sounddevice'] + + +@pytest.fixture(scope='function', autouse=False) +def numpy(): + """Mock numpy module.""" + numpy = mock.MagicMock() + sys.modules['numpy'] = numpy + yield numpy + del sys.modules['numpy'] diff --git a/library/tests/test_noise.py b/library/tests/test_noise.py new file mode 100644 index 0000000..75aa89c --- /dev/null +++ b/library/tests/test_noise.py @@ -0,0 +1,48 @@ +import pytest + + +def test_noise_setup(sounddevice, numpy): + from enviroplus.noise import Noise + + noise = Noise(sample_rate=16000, duration=0.1) + del noise + + +def test_noise_get_amplitudes_at_frequency_ranges(sounddevice, numpy): + from enviroplus.noise import Noise + + noise = Noise(sample_rate=16000, duration=0.1) + noise.get_amplitudes_at_frequency_ranges([ + (100, 500), + (501, 1000) + ]) + + sounddevice.rec.assert_called_with(0.1 * 16000, samplerate=16000, blocking=True, channels=1, dtype='float64') + + +def test_noise_get_noise_profile(sounddevice, numpy): + from enviroplus.noise import Noise + + numpy.mean.return_value = 10.0 + + noise = Noise(sample_rate=16000, duration=0.1) + amp_low, amp_mid, amp_high, amp_total = noise.get_noise_profile( + noise_floor=100, + low=0.12, + mid=0.36, + high=None) + + sounddevice.rec.assert_called_with(0.1 * 16000, samplerate=16000, blocking=True, channels=1, dtype='float64') + + assert amp_total == 10.0 + + +def test_get_amplitude_at_frequency_range(sounddevice, numpy): + from enviroplus.noise import Noise + + noise = Noise(sample_rate=16000, duration=0.1) + + noise.get_amplitude_at_frequency_range(0, 8000) + + with pytest.raises(ValueError): + noise.get_amplitude_at_frequency_range(0, 16000) diff --git a/library/tests/test_setup.py b/library/tests/test_setup.py new file mode 100644 index 0000000..2aa7b49 --- /dev/null +++ b/library/tests/test_setup.py @@ -0,0 +1,66 @@ +def test_gas_setup(GPIO, smbus): + from enviroplus import gas + gas._is_setup = False + gas.setup() + gas.setup() + + +def test_gas_read_all(GPIO, smbus): + from enviroplus import gas + gas._is_setup = False + result = gas.read_all() + + assert type(result.oxidising) == float + assert int(result.oxidising) == 16641 + + assert type(result.reducing) == float + assert int(result.reducing) == 16727 + + assert type(result.nh3) == float + assert int(result.nh3) == 16813 + + assert "Oxidising" in str(result) + + +def test_gas_read_each(GPIO, smbus): + from enviroplus import gas + gas._is_setup = False + + assert int(gas.read_oxidising()) == 16641 + assert int(gas.read_reducing()) == 16727 + assert int(gas.read_nh3()) == 16813 + + +def test_gas_read_adc(GPIO, smbus): + from enviroplus import gas + gas._is_setup = False + + gas.enable_adc(True) + gas.set_adc_gain(2.048) + assert gas.read_adc() == 0.255 + + +def test_gas_read_adc_default_gain(GPIO, smbus): + from enviroplus import gas + gas._is_setup = False + + gas.enable_adc(True) + gas.set_adc_gain(gas.MICS6814_GAIN) + assert gas.read_adc() == 0.765 + + +def test_gas_read_adc_str(GPIO, smbus): + from enviroplus import gas + gas._is_setup = False + + gas.enable_adc(True) + gas.set_adc_gain(2.048) + assert 'ADC' in str(gas.read_all()) + + +def test_gas_cleanup(GPIO, smbus): + from enviroplus import gas + + gas.cleanup() + + GPIO.output.assert_called_with(gas.MICS6814_HEATER_PIN, 0) diff --git a/library/tox.ini b/library/tox.ini new file mode 100644 index 0000000..aa96216 --- /dev/null +++ b/library/tox.ini @@ -0,0 +1,24 @@ +[tox] +envlist = py{27,35},qa +skip_missing_interpreters = True + +[testenv] +commands = + python setup.py install + coverage run -m py.test -v -r wsx + coverage report +deps = + mock + pytest>=3.1 + pytest-cov + +[testenv:qa] +commands = + check-manifest --ignore tox.ini,tests*,.coveragerc + python setup.py check -m -r -s + flake8 --ignore E501 + rstcheck README.rst +deps = + check-manifest + flake8 + rstcheck diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..e317444 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +LIBRARY_VERSION=`cat library/setup.cfg | grep version | awk -F" = " '{print $2}'` +LIBRARY_NAME=`cat library/setup.cfg | grep name | awk -F" = " '{print $2}'` + +printf "$LIBRARY_NAME $LIBRARY_VERSION Python Library: Uninstaller\n\n" + +if [ $(id -u) -ne 0 ]; then + printf "Script must be run as root. Try 'sudo ./uninstall.sh'\n" + exit 1 +fi + +cd library + +printf "Unnstalling for Python 2..\n" +pip uninstall $LIBRARY_NAME + +if [ -f "/usr/bin/pip3" ]; then + printf "Uninstalling for Python 3..\n" + pip3 uninstall $LIBRARY_NAME +fi + +cd .. + +printf "Disabling serial..\n" +# Enable serial terminal over /dev/ttyAMA0 +raspi-config nonint do_serial 0 +# Disable serial port +raspi-config nonint set_config_var enable_uart 0 /boot/config.txt +# Switch serial port back to miniUART +sed -i 's/^dtoverlay=pi3-miniuart-bt # for Enviro+/#dtoverlay=pi3-miniuart-bt # for Enviro+/' /boot/config.txt + +printf "Done!\n"