From bcf2c73bdfaf3f9376d2a4b1c42f2df8424efb5d Mon Sep 17 00:00:00 2001 From: Paul Beech Date: Tue, 12 May 2020 04:24:11 +0100 Subject: [PATCH] Initial commit --- .gitattributes | 2 + .gitignore | 21 ++ .stickler.yml | 5 + .travis.yml | 25 ++ LICENSE | 21 ++ Makefile | 70 +++++ README.md | 55 ++++ examples/adc.py | 27 ++ examples/all-in-one-no-pm.py | 190 +++++++++++++ examples/all-in-one.py | 230 +++++++++++++++ examples/combined.py | 348 +++++++++++++++++++++++ examples/compensated-temperature.py | 53 ++++ examples/gas.py | 24 ++ examples/icons/bulb-bright.png | Bin 0 -> 2533 bytes examples/icons/bulb-dark.png | Bin 0 -> 2817 bytes examples/icons/bulb-dim.png | Bin 0 -> 2817 bytes examples/icons/bulb-light.png | Bin 0 -> 2820 bytes examples/icons/humidity-bad.png | Bin 0 -> 2983 bytes examples/icons/humidity-good.png | Bin 0 -> 2530 bytes examples/icons/humidity.png | Bin 0 -> 2983 bytes examples/icons/temperature.png | Bin 0 -> 3188 bytes examples/icons/weather-change.png | Bin 0 -> 2770 bytes examples/icons/weather-dry.png | Bin 0 -> 3155 bytes examples/icons/weather-fair.png | Bin 0 -> 3155 bytes examples/icons/weather-rain.png | Bin 0 -> 2850 bytes examples/icons/weather-storm.png | Bin 0 -> 2732 bytes examples/lcd.py | 65 +++++ examples/light.py | 33 +++ examples/luftdaten.py | 188 ++++++++++++ examples/noise-amps-at-freqs.py | 44 +++ examples/noise-profile.py | 40 +++ examples/particulates.py | 29 ++ examples/weather-and-light.py | 425 ++++++++++++++++++++++++++++ examples/weather.py | 35 +++ install.sh | 193 +++++++++++++ library/.coveragerc | 4 + library/CHANGELOG.txt | 16 ++ library/LICENSE.txt | 21 ++ library/MANIFEST.in | 5 + library/README.rst | 93 ++++++ library/grow/__init__.py | 1 + library/grow/gas.py | 140 +++++++++ library/grow/noise.py | 90 ++++++ library/setup.cfg | 79 ++++++ library/setup.py | 33 +++ library/tests/conftest.py | 90 ++++++ library/tests/test_noise.py | 48 ++++ library/tests/test_setup.py | 66 +++++ library/tox.ini | 24 ++ uninstall.sh | 33 +++ 50 files changed, 2866 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .stickler.yml create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 examples/adc.py create mode 100644 examples/all-in-one-no-pm.py create mode 100644 examples/all-in-one.py create mode 100644 examples/combined.py create mode 100644 examples/compensated-temperature.py create mode 100644 examples/gas.py create mode 100644 examples/icons/bulb-bright.png create mode 100644 examples/icons/bulb-dark.png create mode 100644 examples/icons/bulb-dim.png create mode 100644 examples/icons/bulb-light.png create mode 100644 examples/icons/humidity-bad.png create mode 100644 examples/icons/humidity-good.png create mode 100644 examples/icons/humidity.png create mode 100644 examples/icons/temperature.png create mode 100644 examples/icons/weather-change.png create mode 100644 examples/icons/weather-dry.png create mode 100644 examples/icons/weather-fair.png create mode 100644 examples/icons/weather-rain.png create mode 100644 examples/icons/weather-storm.png create mode 100644 examples/lcd.py create mode 100644 examples/light.py create mode 100644 examples/luftdaten.py create mode 100644 examples/noise-amps-at-freqs.py create mode 100644 examples/noise-profile.py create mode 100644 examples/particulates.py create mode 100644 examples/weather-and-light.py create mode 100644 examples/weather.py create mode 100644 install.sh create mode 100644 library/.coveragerc create mode 100644 library/CHANGELOG.txt create mode 100644 library/LICENSE.txt create mode 100644 library/MANIFEST.in create mode 100644 library/README.rst create mode 100644 library/grow/__init__.py create mode 100644 library/grow/gas.py create mode 100644 library/grow/noise.py create mode 100644 library/setup.cfg create mode 100644 library/setup.py create mode 100644 library/tests/conftest.py create mode 100644 library/tests/test_noise.py create mode 100644 library/tests/test_setup.py create mode 100644 library/tox.ini create mode 100644 uninstall.sh 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 0000000000000000000000000000000000000000..5697a81bf2fe2f2a7f45f7e448650ba321cb92e5 GIT binary patch literal 2533 zcmbVN2~-nz8XrYak<ww?E8@;`px@4LU8^zv{c z=x@~r0D!=E=lS5jCfav{9{&APbRY--E7~Mnu9ue*^$JHZ6!dX(0RZc)v)O_OU+2x5 z^L(pF@4Z=T$g3T880*#xHve_%fpyNmG(F_(zVBb(XH<61)QjZhA37&!=5&k>RgRfY zCT%`AV|fSZC#DzdG5X;~OxXQs)-yz}d8Fo#-DH_dHneTLTS+x-DyI}%Ry4<5JN`Ap zzb8WtIMw#($L3F-N(}DZ2;BVw-}c8AyH!SlMRkURuR;)nGiZzseBXJ-dqe z)$d}|u08dNRrz~&M^-=RNP0f(;*eP6;X}wLfsal;_JUU4-p((`o7KC_8f#mDmCZi* z>gmbT<(J9k$9;2Bu8h;)-<5OLwyANv)MA90+oCa;n8;7RYv}Oj$NSRR)amz5*#ZX# zdx|+t2X}mO_?StokXe|q{Zea+ft$wag4@i#)@Tz0H)Y;jwYTNYp?ZGciPo#^^TYKI zGYaDzzG+BJEgT{HC+eH_9&QR4ot6J1=)J?aEu$|7+ZtwSW4#Z2|HRlC?sb|owMxDlS@wF>t@6##76u6}krph&okQ$#AI z^U6g+3L-^hh#XVlSlTickCH&J41XuBOty?xVXjAUixz!*CtBqftw1O~hzgBV3K7?% zxJJt*!xY+%R)wSg#gFCdWp5M^SBbzFs`Nuq=~ANR=Yp06BoRmdFO*0KiBY8t#$zLq z!C?qRArG6^ArL@(xe9~jLWIv_6LH(f5{U?MuxBvs5T=ksV}y!Hj8LYS6v`6YljwB1 zJ&j7EGMG%^k~|L;Mr!l6Brp1v@}5cw-Z!xHxAQFL1>&MoR4G73cv$U-;7{j#d}1q6 zu|$er{Csk2D|^1eAa{uhPh<3AulOQI7FSXUIA2r{EY#LLn<&&K91#&0k0rl3dJDAb za0DlPcMU8cRHztJ!%DevxiO;T0#v^qAu(E*9bM^~3{-`6^-PhZ6wW8$9qkF6qk#T>7{Tsjdz zo71owQu-(o;YgYL)$4mU(db)+o2a4 z%bd^Iy)`Ctdv=#)H;Pvs_|}lTgE8>-&NJlW1VX$i=rh_$SQppz;^1J)ps{I1>6aHL z@06(&^?AXim8q>94!NVky5MSFghOzc^f2e`Yr>fij9FvTVA{iN5d+(7PIV5mA`B`g zcg4J}SLa+v(A3HuH)IKO+nMA)+&CnjX)eFj6l+nT`no%=Y@-=1m+$%cMX)$1(W867 zhZVcWvxE)nJ_%fdet@+%5V#LGI`%R8_ooNi?@ZtDJaTBALA=)&(&Cgenm7KgCfG;N zj=nt<>{eqw)6pZ$sxQCf_f^l;CFWKH6{j(1jOtv=>5g;scds=Ro5!}VJzwZ|Q(`*0 zW;_aHSvKFS=nA%N{F^Ub7MO9Y(aQ#VYMN%2 zsw;?)!+Y03<_!ZnZ%B{M^pA~@GOOg@>I2D)7wyb|O zb+5^3@LXj@MYnN@niE^;`u4WH4$N$O`nb0!f&H-NhgU6zE`R?PRz-fAS5Ntjr#&1aPZ@Kx;INv9?_Fg@kRc)Pi_g)Ln@k-GgBs=P9Bb_3= t?>sl%Zk<-`v;W9hy;l?5QL`!a=;}7%Yn_)i8e8pOHQ&{Pcg-av`QNaA<`Vz_ literal 0 HcmV?d00001 diff --git a/examples/icons/bulb-dark.png b/examples/icons/bulb-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..a91e24b580505621046235f3ded1a7058bb0281a GIT binary patch literal 2817 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 literal 0 HcmV?d00001 diff --git a/examples/icons/weather-change.png b/examples/icons/weather-change.png new file mode 100644 index 0000000000000000000000000000000000000000..21215b78d5a1946229fb900aac9fe2dccc7b10d0 GIT binary patch literal 2770 zcmcIm3se(V8jjim0_sv+krmtNAd1RlW|GNkG*VuMC}APc1(aGQlL?uHWa1>4K&_zV zQQBHdu!|L+tvs|;UAvyGg;nuU0Y#84^+C5HJ<3wqMyl2atnLht)YIy&r{`?WnatdK zzx(~K@BTBT3Gu2)-oNt}2n3VVvC&%Y9>`z6@!p?WfF{xjRCZ( zNP!@LmgH`ZO=|Y_6i0@@R4W?cGa4Q=}80aqAL` z1T`Oo>w-UPd3|ruIV`1XgI%!jNSF8Os=IF&yn5MBaO$tzuxE9h>QD}_=cvD_+V9QG zqt^q3c3_R*<$8bHOGD2{znGtAkLhZ(JX5vwrJRQL^J~BRK2lb&Ctf?L3IaMyze>Qz z9}ZDh?;P@|Rov_tXFD*|{>8VYy^m;$!~`V()+sV!kg1)zp>)Az}2L*W<$tFZH2z7;#oUXgK=%Sfxaqs z=YxjC;CcOx>a;i8K2~o3zVYuHYF?LpbF8GK#wAQC@D94XqB(V7$b5SFL2(NaeRklt<`icG1wV>~F z+sCWo*G7Ga7pY%_^kDcRV4(x&2uzfX z034=lvlVwJ!C_vUd*-KM5EzEovXo#1ZxBe;BmhwiO92w06e3Yf0>~6XR4T?4iZDQg zpmG?I!>AZSr8tJ*A`vk9137D~-hgYP7mwQFMoQ3VvsrK$w%hGOyI9DunJ}tQC}2bc zi$oBIfULP@8{vS=*5EOQXv#{mw8cg(mLhBno6IoTV}%;-3p@)D6~z}u z0fcG@k~Z_yb9qyvyQpZwMkzra0ih^_$dgeNmq>B3eAx&}!|6az*oa@^L^vwPMT#*T zqo)nI{|%)laRbAe2rf6YiO8g2i#c=HhDL*{%~l&>CMk8a66Aan(zG7ei^T*fBQQv& z*JBWdU?PYxNOcfGpaz{tE+=$^Y;1otL+0>xe&qEX|!FBKuKR-YY%FD3f4PF(oA-6w?_XOeZx!I)y<3 zi9{j^X9B^bQW6AyvKz%@GpvTub4eCII`dW6<)q&U*YD`Es;Weheu!BP>KT*{wL zaqyFzw-T%VhwD;QB*icVB$kLN2$P7#kPeaSAQ?&O400XCX+%C2`Y&DoNuY(qxFrmpTd#r2}ji3wSS_s3s2=i$HM;@MLN3aV$~{l}Izw7oz1(!TID zt}VsYrdrXx%5tjgWZFuP-pW>w>78ETZO!bbDoN>Kg`%p%OA-(_e`xB?^!;_qca=J> z5nlM2xcLolHHFH*o{|!Vkjee$J`4HU`}9`C(D~Z4bDc}J-R*w9Khf#)Llb^grfCxS z&8zh(s;_y^`;8X%K-%IzU7=q-`s2P(MPK3lIf09UUimOBaaqRpzoZ^&4GI5bV+if( zx49S5o_KL!U>*i9)9OpMEZHmXEB13<$kU#%c=h(~nz1-Gtb9fO?RyH#>1pl9OY8=4 z$Fhic$W?sK$AZn!<{%$z>G~lkHo)mq2Zr~|0)kyI{6}MbNtVAQW5Vawz_%RE&dFJy zXVCMLzdV+rMU1aDeYr?GeqHnBwe?e;f2Z-bw{gLp^zND_S95-T=EK~3;+Ss)OC6hv Ug65=L*Z*`SQRmW$##i#Tb+bk}8 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 literal 0 HcmV?d00001 diff --git a/examples/icons/weather-storm.png b/examples/icons/weather-storm.png new file mode 100644 index 0000000000000000000000000000000000000000..20172459e6d0f2f58b0e4bf61310592ba25b4ead GIT binary patch literal 2732 zcmcIm3se->86LsHf`}p(d=-b~5o(y7$G!(PxC>-ib;l*u5~_#~Vp4_Es8}FA5CYL+s}xd=&wx>hk*F1nompVxNi@gPp3XV5JNMr2 ze*f#c|4fN4Q9adrjyC`RQ#A>3$?X4Z?w;=fTDan}AJG06c6fD7gZfAgxWCokyvJ`#X6sjh zf?RMN@a8dp$HLLclGCrQ&W-PHvrevCyfCNv+{N{`?y97P`xBF=) zTGk}Jb@N|7T|56AzV+SVDV2eV3wB-Omv_qMs+(Gu?ibwXNw4nqLT(cu%}VC`%&2iw zC$5kE6JpeRS8UsMVupWGNUOSbZ%BDy?-!8)1=HR?7=6fm|-7?sQAFoMAt z2C)dpo^Npwd62~(GQki>+ew?r>M$`DkYgnDOpZgrXFVMY!EANWTI}O#ViN}E5mp!# zAY4dJAVs=x)*M^5(>O)KbT)0KEe<=2MO|1nOIoc9{#;tK*@d<{;#aXAjmP#}v^_Q7 zO2f&toyoD0bo?sTV#tJJR_;XGjm!(;a4ox{vEs2dns6|-REEi(D3r4=a4cYKELRvg z7@;LdlZB)HC1+}U7ad19Xa%1`Kqv|!vQ!ks#S&a7`_&jq%j$q49K?@t7>=Phf=%EU z%4Ep@DHKKG2F7M4*xZ=RL?#VeEtyUmS}m@z*d2t0q&0C0KI@ynWTJ3dE+(XyR1P7C z5P?JzIRWVrS`3K{C?zKh61`Y1o7f-6kU3o4xcy@irWlgtaAgaZi42qolZhY{qY(&^ zi%>`=)Jq|R#-vg?D#1tun_zpxW@1kfk^L+y=M}{=O2s0Hkd}}TD$*MukzQhe^m2n3 z!Z1vXA}At~NJu{Tz1>(Qo3UvbicPZc*?A4yzio`cl+7+Y+s8SbJN1SqnCxs(^T$t4 z3cYgtDcb}(C596ucM24Ik}E$=@yDl4&&lP>c|As(YouAy=PP0iVP_1ET*5{xGuf2C zoZ{dYId3Oc{j}?}P)bu0LJ!IHh=i4aSi%*K%Rh#S2$Z7rdROQ_cKv&i{@1RLr^r6^BfXUUS~;bOQ>LaBk)uzY*X-ixjM^o~+Nbaom;F?S?<)tM&X9>dRu0ace(U5b zygQy#J6lT6+0K;3HhIy1cG9iZyLla0FbJ5>XV4+js^%OfD)x@oF`9c?G)bn!~(^enu{ z@ZMfT+|x#8P@%gwbj>RNG~|u>`!c?i=-;du^s8I&&2YJQ#a;gc6AP^$P5iJ&$a~ULBD2TDb_ZW_w=TT%&Ah1Nug)kq?&h9! z;7rrz(?J=JJ!@OwlSAvX4sGvKU%PrfX|l@ReCk8Nt*AOlQm-*|a@WHvl`A0kTDZCP z@8Q^DNZ%$<^6otLJ=Q#3xM|(fvgB&x_Qu}_DUJ7QdT(U?Ay{-HsL;KSvYim!u6i6F zWxl;DJ!qw-Rj~7q33Eqg8GjzKxi^WtN-g+6@Amrnh_cAa;&Y!zfsTQ9`g%74B0~R9@%4I5hEzaR*fy8~y`E!S9~{ literal 0 HcmV?d00001 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"