diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..07620e34 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,41 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.9', '3.10', '3.11'] + + env: + RELEASE_FILE: ${{ github.event.repository.name }}-${{ github.event.release.tag_name || github.sha }}-py${{ matrix.python }} + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install Dependencies + run: | + make dev-deps + + - name: Build Packages + run: | + make build + + - name: Upload Packages + uses: actions/upload-artifact@v4 + with: + name: ${{ env.RELEASE_FILE }} + path: dist/ diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 00000000..ac672a52 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,39 @@ +name: QA + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: linting & spelling + runs-on: ubuntu-latest + env: + TERM: xterm-256color + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python '3,11' + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Dependencies + run: | + make dev-deps + + - name: Run Quality Assurance + run: | + make qa + + - name: Run Code Checks + run: | + make check + + - name: Run Bash Code Checks + run: | + make shellcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..6f8cff73 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,41 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Python ${{ matrix.python }} + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.9', '3.10', '3.11'] + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install Dependencies + run: | + make dev-deps + + - name: Run Tests + run: | + make pytest + + - name: Coverage + if: ${{ matrix.python == '3.9' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + python -m pip install coveralls + coveralls --service=github + diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 79decd6f..00000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..47cb04d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +1.0.2 +----- + +* README.md: Update install instructions +* Fix installer to enable serial +* Fix gas sensor heater pin + +1.0.1 +----- + +* README.md: Fix images + +1.0.0 +----- + +* BREAKING: Port to gpiod/gpiodevice for Pi 5/Bookworm. + +0.0.6 +----- + +* Fix noise by specifying adau7002 device + +0.0.5 +----- + +* Drop Python 2.x support +* Add "available()" method for gas sensor + +0.0.4 +----- + +* Add support for ads1015 >= v0.0.7 (ADS1115 ADCs) +* Packaging tweaks + +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/Enviro-Plus-pHAT.jpg b/Enviro-Plus-pHAT.jpg new file mode 100644 index 00000000..f0947a09 Binary files /dev/null and b/Enviro-Plus-pHAT.jpg differ diff --git a/Enviro-mini-pHAT.jpg b/Enviro-mini-pHAT.jpg new file mode 100644 index 00000000..120469f4 Binary files /dev/null and b/Enviro-mini-pHAT.jpg differ diff --git a/library/MANIFEST.in b/MANIFEST.in similarity index 83% rename from library/MANIFEST.in rename to MANIFEST.in index 43329d99..478b3f30 100644 --- a/library/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,5 @@ include CHANGELOG.txt include LICENSE.txt -include README.rst +include README.md include setup.py recursive-include enviroplus *.py diff --git a/Makefile b/Makefile index d2bba498..56cf0dfe 100644 --- a/Makefile +++ b/Makefile @@ -1,70 +1,66 @@ -LIBRARY_VERSION=$(shell grep version library/setup.cfg | awk -F" = " '{print $$2}') -LIBRARY_NAME=$(shell grep name library/setup.cfg | awk -F" = " '{print $$2}') +LIBRARY_NAME := $(shell hatch project metadata name 2> /dev/null) +LIBRARY_VERSION := $(shell hatch version 2> /dev/null) -.PHONY: usage install uninstall +.PHONY: usage install uninstall check pytest qa build-deps check tag wheel sdist clean dist testdeploy deploy usage: +ifdef LIBRARY_NAME @echo "Library: ${LIBRARY_NAME}" @echo "Version: ${LIBRARY_VERSION}\n" +else + @echo "WARNING: You should 'make dev-deps'\n" +endif @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" + @echo "install: install the library locally from source" + @echo "uninstall: uninstall the local library" + @echo "dev-deps: install Python dev dependencies" + @echo "check: perform basic integrity checks on the codebase" + @echo "qa: run linting and package QA" + @echo "pytest: run Python test fixtures" + @echo "clean: clean Python build and dist directories" + @echo "build: build Python distribution files" + @echo "testdeploy: build and upload to test PyPi" + @echo "deploy: build and upload to PyPi" + @echo "tag: tag the repository with the current version\n" + +version: + @hatch version install: - ./install.sh + ./install.sh --unstable 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}" +dev-deps: + python3 -m pip install -r requirements-dev.txt + sudo apt install dos2unix shellcheck -python-readme: library/README.rst +check: + @bash check.sh -python-license: library/LICENSE.txt +shellcheck: + shellcheck *.sh -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 +qa: + tox -e qa -library/LICENSE.txt: LICENSE - cp LICENSE library/LICENSE.txt +pytest: + tox -e py -python-wheels: python-readme python-license - cd library; python3 setup.py bdist_wheel - cd library; python setup.py bdist_wheel +nopost: + @bash check.sh --nopost -python-sdist: python-readme python-license - cd library; python setup.py sdist +tag: version + git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" -python-clean: - -rm -r library/dist - -rm -r library/build - -rm -r library/*.egg-info +build: check + @hatch build -python-dist: python-clean python-wheels python-sdist - ls library/dist +clean: + -rm -r dist -python-testdeploy: python-dist - twine upload --repository-url https://test.pypi.org/legacy/ library/dist/* +testdeploy: build + twine upload --repository testpypi dist/* -python-deploy: check python-dist - twine upload library/dist/* +deploy: nopost build + twine upload dist/* diff --git a/README.md b/README.md index 5fb9fe6b..b2033326 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,78 @@ 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) +[![Build Status](https://img.shields.io/github/actions/workflow/status/pimoroni/enviroplus-python/test.yml?branch=main)](https://github.com/pimoroni/enviroplus-python/actions/workflows/test.yml) +[![Coverage Status](https://coveralls.io/repos/github/pimoroni/enviroplus-python/badge.svg?branch=main)](https://coveralls.io/github/pimoroni/enviroplus-python?branch=main) [![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. +**Note** The code in this repository supports both the Enviro+ and Enviro Mini boards. _The Enviro Mini board does not have the Gas sensor or the breakout for the PM sensor._ -## One-line (Installs from GitHub) +![Enviro Plus pHAT](https://raw.githubusercontent.com/pimoroni/enviroplus-python/main/Enviro-Plus-pHAT.jpg) +![Enviro Mini pHAT](https://raw.githubusercontent.com/pimoroni/enviroplus-python/main/Enviro-mini-pHAT.jpg) -``` -curl -sSL https://get.pimoroni.com/enviroplus | bash -``` - -**Note** report issues with one-line installer here: https://github.com/pimoroni/get +:warning: This library now supports Python 3 only, Python 2 is EOL - https://www.python.org/doc/sunset-python-2/ -## Or... Install and configure dependencies from GitHub: +## Install and configure dependencies from GitHub: * `git clone https://github.com/pimoroni/enviroplus-python` * `cd enviroplus-python` -* `sudo ./install.sh` +* `./install.sh` -**Note** Raspbian Lite users may first need to install git: `sudo apt install git` +**Note** Libraries will be installed in the "pimoroni" virtual environment, you will need to activate it to run examples: + +``` +source ~/.virtualenvs/pimoroni/bin/activate +``` + +**Note** Raspbian/Raspberry Pi OS Lite users may first need to install git: `sudo apt install git` ## Or... Install from PyPi and configure manually: -* Run `sudo pip install enviroplus` +* `python3 -m venv --system-site-packages $HOME/.virtualenvs/pimoroni` +* Run `python3 -m pip install enviroplus` -**Note** this wont perform any of the required configuration changes on your Pi, you may additionally need to: +And install additional dependencies: + +```bash +sudo apt install python3-numpy python3-smbus python3-pil python3-setuptools +``` + +**Note** this will not 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: +### Bookworm + +* Enable serial: `raspi-config nonint do_serial_hw 0` +* Disable serial terminal: `raspi-config nonint do_serial_cons 1` +* Add `dtoverlay=pi3-miniuart-bt` to your `/boot/firmware/config.txt` + +### Bullseye + * 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: +## Alternate Software & User Projects -``` -sudo apt install python-numpy python-smbus python-pil python-setuptools -``` +* Enviro Plus Dashboard - https://gitlab.com/dedSyn4ps3/enviroplus-dashboard - A React-based web dashboard for viewing sensor data +* Enviro+ Example Projects - https://gitlab.com/dedSyn4ps3/enviroplus-python-projects - Includes original examples plus code to stream to Adafruit IO (more projects coming soon) +* enviro monitor - https://github.com/roscoe81/enviro-monitor +* mqtt-all - https://github.com/robmarkcole/rpi-enviro-mqtt - now upstream: [see examples/mqtt-all.py](examples/mqtt-all.py) +* enviroplus_exporter - https://github.com/tijmenvandenbrink/enviroplus_exporter - Prometheus exporter (with added support for Luftdaten and InfluxDB Cloud) +* homekit-enviroplus - https://github.com/sighmon/homekit-enviroplus - An Apple HomeKit accessory for the Pimoroni Enviro+ +* go-enviroplus - https://github.com/rubiojr/go-enviroplus - Go modules to read Enviro+ sensors +* homebridge-enviroplus - https://github.com/mhawkshaw/homebridge-enviroplus - a Homebridge plugin to add the Enviro+ to HomeKit via Homebridge +* Enviro Plus Web - https://gitlab.com/idotj/enviroplusweb - Simple Flask application serves a web page with the current sensor readings and a graph over a specified time period ## Help & Support * GPIO Pinout - https://pinout.xyz/pinout/enviro_plus -* Support forums - http://forums.pimoroni.com/c/support +* Support forums - https://forums.pimoroni.com/c/support * Discord - https://discord.gg/hr93ByC diff --git a/check.sh b/check.sh new file mode 100755 index 00000000..38dfc3a1 --- /dev/null +++ b/check.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# This script handles some basic QA checks on the source + +NOPOST=$1 +LIBRARY_NAME=$(hatch project metadata name) +LIBRARY_VERSION=$(hatch version | awk -F "." '{print $1"."$2"."$3}') +POST_VERSION=$(hatch version | awk -F "." '{print substr($4,0,length($4))}') +TERM=${TERM:="xterm-256color"} + +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)" +} + +while [[ $# -gt 0 ]]; do + K="$1" + case $K in + -p|--nopost) + NOPOST=true + shift + ;; + *) + if [[ $1 == -* ]]; then + printf "Unrecognised option: %s\n" "$1"; + exit 1 + fi + POSITIONAL_ARGS+=("$1") + shift + esac +done + +inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" + +inform "Checking for trailing whitespace..." +if grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO; then + warning "Trailing whitespace found!" + exit 1 +else + success "No trailing whitespace found." +fi +printf "\n" + +inform "Checking for DOS line-endings..." +if grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile; then + warning "DOS line-endings found!" + exit 1 +else + success "No DOS line-endings found." +fi +printf "\n" + +inform "Checking CHANGELOG.md..." +if ! grep "^${LIBRARY_VERSION}" CHANGELOG.md > /dev/null 2>&1; then + warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." + exit 1 +else + success "Changes found for version ${LIBRARY_VERSION}." +fi +printf "\n" + +inform "Checking for git tag ${LIBRARY_VERSION}..." +if ! git tag -l | grep -E "${LIBRARY_VERSION}$"; then + warning "Missing git tag for version ${LIBRARY_VERSION}" +fi +printf "\n" + +if [[ $NOPOST ]]; then + inform "Checking for .postN on library version..." + if [[ "$POST_VERSION" != "" ]]; then + warning "Found .$POST_VERSION on library version." + inform "Please only use these for testpypi releases." + exit 1 + else + success "OK" + fi +fi diff --git a/enviroplus/__init__.py b/enviroplus/__init__.py new file mode 100644 index 00000000..7863915f --- /dev/null +++ b/enviroplus/__init__.py @@ -0,0 +1 @@ +__version__ = "1.0.2" diff --git a/library/enviroplus/gas.py b/enviroplus/gas.py similarity index 59% rename from library/enviroplus/gas.py rename to enviroplus/gas.py index 584317b6..1e78010f 100644 --- a/library/enviroplus/gas.py +++ b/enviroplus/gas.py @@ -1,21 +1,28 @@ """Read the MICS6814 via an ads1015 ADC""" -import time import atexit +import time + import ads1015 -import RPi.GPIO as GPIO +import gpiod +import gpiodevice +from gpiod.line import Direction, Value -MICS6814_HEATER_PIN = 24 MICS6814_GAIN = 6.144 +OUTH = gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.ACTIVE) + + ads1015.I2C_ADDRESS_DEFAULT = ads1015.I2C_ADDRESS_ALTERNATE _is_setup = False +_is_available = False _adc_enabled = False _adc_gain = 6.148 +_heater = None class Mics6814Reading(object): - __slots__ = 'oxidising', 'reducing', 'nh3', 'adc' + __slots__ = "oxidising", "reducing", "nh3", "adc" def __init__(self, ox, red, nh3, adc=None): self.oxidising = ox @@ -24,40 +31,49 @@ def __init__(self, ox, red, nh3, adc=None): self.adc = adc def __repr__(self): - fmt = """Oxidising: {ox:05.02f} Ohms -Reducing: {red:05.02f} Ohms -NH3: {nh3:05.02f} Ohms""" + fmt = f"""Oxidising: {self.oxidising:05.02f} Ohms +Reducing: {self.reducing:05.02f} Ohms +NH3: {self.nh3:05.02f} Ohms""" if self.adc is not None: - fmt += """ -ADC: {adc:05.02f} Volts + fmt += f""" +ADC: {self.adc:05.02f} Volts """ - return fmt.format( - ox=self.oxidising, - red=self.reducing, - nh3=self.nh3, - adc=self.adc) + return fmt __str__ = __repr__ def setup(): - global adc, _is_setup + global adc, adc_type, _is_setup, _is_available, _heater if _is_setup: return _is_setup = True - adc = ads1015.ADS1015(i2c_addr=0x49) - adc.set_mode('single') + try: + adc = ads1015.ADS1015(i2c_addr=0x49) + adc_type = adc.detect_chip_type() + _is_available = True + except IOError: + _is_available = False + return + + adc.set_mode("single") adc.set_programmable_gain(MICS6814_GAIN) - adc.set_sample_rate(1600) + if adc_type == "ADS1115": + adc.set_sample_rate(128) + else: + adc.set_sample_rate(1600) + + _heater = gpiodevice.get_pin("GPIO24", "EnviroPlus", OUTH) - GPIO.setwarnings(False) - GPIO.setmode(GPIO.BCM) - GPIO.setup(MICS6814_HEATER_PIN, GPIO.OUT) - GPIO.output(MICS6814_HEATER_PIN, 1) atexit.register(cleanup) +def available(): + setup() + return _is_available + + def enable_adc(value=True): """Enable reading from the additional ADC pin.""" global _adc_enabled @@ -71,15 +87,22 @@ def set_adc_gain(value): def cleanup(): - GPIO.output(MICS6814_HEATER_PIN, 0) + if _heater is None: + return + lines, offset = _heater + lines.set_value(offset, Value.INACTIVE) def read_all(): - """Return gas resistence for oxidising, reducing and NH3""" + """Return gas resistance for oxidising, reducing and NH3""" setup() - ox = adc.get_voltage('in0/gnd') - red = adc.get_voltage('in1/gnd') - nh3 = adc.get_voltage('in2/gnd') + + if not _is_available: + raise RuntimeError("Gas sensor not connected.") + + 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) @@ -100,11 +123,11 @@ def read_all(): if _adc_enabled: if _adc_gain == MICS6814_GAIN: - analog = adc.get_voltage('ref/gnd') + analog = adc.get_voltage("ref/gnd") else: adc.set_programmable_gain(_adc_gain) time.sleep(0.05) - analog = adc.get_voltage('ref/gnd') + analog = adc.get_voltage("ref/gnd") adc.set_programmable_gain(MICS6814_GAIN) return Mics6814Reading(ox, red, nh3, analog) @@ -115,7 +138,6 @@ def read_oxidising(): Eg chlorine, nitrous oxide """ - setup() return read_all().oxidising @@ -124,17 +146,14 @@ def read_reducing(): 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/enviroplus/noise.py b/enviroplus/noise.py new file mode 100644 index 00000000..261c3abf --- /dev/null +++ b/enviroplus/noise.py @@ -0,0 +1,85 @@ +import numpy +import sounddevice + + +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(f"Maximum frequency is {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 characteristic 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), + device="adau7002", + samplerate=self.sample_rate, + blocking=True, + channels=1, + dtype="float64" + ) diff --git a/examples/adc.py b/examples/adc.py index c4cf45cf..983aec3a 100755 --- a/examples/adc.py +++ b/examples/adc.py @@ -1,9 +1,16 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import logging import time + from enviroplus import gas -print("""adc.py - Print readings from the MICS6814 Gas sensor. +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! @@ -15,7 +22,7 @@ try: while True: readings = gas.read_all() - print(readings) + logging.info(readings) time.sleep(1.0) except KeyboardInterrupt: pass diff --git a/examples/all-in-one-enviro-mini.py b/examples/all-in-one-enviro-mini.py new file mode 100755 index 00000000..4aea5c4d --- /dev/null +++ b/examples/all-in-one-enviro-mini.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 + +import colorsys +import os +import sys +import time + +import st7735 + +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +import logging +from subprocess import PIPE, Popen + +from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont + +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="GPIO9", + backlight="GPIO12", + 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 = f"{variable[:4]}: {data:.1f} {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"] + +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) + +# Exit cleanly +except KeyboardInterrupt: + sys.exit(0) diff --git a/examples/all-in-one-no-pm.py b/examples/all-in-one-no-pm.py old mode 100644 new mode 100755 index 367ebeb0..c0172054 --- a/examples/all-in-one-no-pm.py +++ b/examples/all-in-one-no-pm.py @@ -1,20 +1,33 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 -import time import colorsys import os import sys -import ST7735 -import ltr559 +import time + +import st7735 + +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +import logging from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont + from enviroplus import gas -from subprocess import PIPE, Popen -from PIL import Image -from PIL import ImageDraw -from PIL import ImageFont -print("""all-in-one.py - Displays readings from all of Enviro plus' sensors +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! """) @@ -22,11 +35,11 @@ bme280 = BME280() # Create ST7735 LCD display class -st7735 = ST7735.ST7735( +st7735 = st7735.ST7735( port=0, cs=1, - dc=9, - backlight=12, + dc="GPIO9", + backlight="GPIO12", rotation=270, spi_speed_hz=10000000 ) @@ -38,10 +51,11 @@ HEIGHT = st7735.height # Set up canvas and font -img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) draw = ImageDraw.Draw(img) path = os.path.dirname(os.path.realpath(__file__)) -font = ImageFont.truetype(path + "/fonts/Asap/Asap-Bold.ttf", 20) +font_size = 20 +font = ImageFont.truetype(UserFont, font_size) message = "" @@ -54,23 +68,22 @@ 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 - colours = [(v - min(values[variable]) + 1) / (max(values[variable]) - - min(values[variable]) + 1) for v in values[variable]] + 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) - print(message) + message = f"{variable[:4]}: {data:.1f} {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)] + 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.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)) + 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) @@ -78,16 +91,17 @@ def display_text(variable, data, unit): # Get the temperature of the CPU for compensation def get_cpu_temperature(): - process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE) - output, _error = process.communicate() - return float(output[output.index('=') + 1:output.rindex("'")]) + 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 = 0.8 +factor = 2.25 -cpu_temps = [0] * 5 +cpu_temps = [get_cpu_temperature()] * 5 delay = 0.5 # Debounce the proximity tap mode = 0 # The starting mode @@ -121,57 +135,57 @@ def get_cpu_temperature(): # One mode for each variable if mode == 0: - variable = "temperature" - unit = "C" + # 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(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 1: - variable = "pressure" + # variable = "pressure" unit = "hPa" data = bme280.get_pressure() - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 2: - variable = "humidity" + # variable = "humidity" unit = "%" data = bme280.get_humidity() - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 3: - variable = "light" + # variable = "light" unit = "Lux" if proximity < 10: data = ltr559.get_lux() else: data = 1 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 4: - variable = "oxidised" + # variable = "oxidised" unit = "kO" data = gas.read_all() data = data.oxidising / 1000 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 5: - variable = "reduced" + # variable = "reduced" unit = "kO" data = gas.read_all() data = data.reducing / 1000 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 6: - variable = "nh3" + # variable = "nh3" unit = "kO" data = gas.read_all() data = data.nh3 / 1000 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) # Exit cleanly except KeyboardInterrupt: diff --git a/examples/all-in-one.py b/examples/all-in-one.py index 8ba76815..b558f486 100755 --- a/examples/all-in-one.py +++ b/examples/all-in-one.py @@ -1,21 +1,34 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 -import time import colorsys -import os import sys -import ST7735 -import ltr559 +import time + +import st7735 + +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +import logging from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont from pms5003 import PMS5003 +from pms5003 import ReadTimeoutError as pmsReadTimeoutError + from enviroplus import gas -from subprocess import PIPE, Popen -from PIL import Image -from PIL import ImageDraw -from PIL import ImageFont -print("""all-in-one.py - Displays readings from all of Enviro plus' sensors +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! @@ -28,11 +41,11 @@ pms5003 = PMS5003() # Create ST7735 LCD display class -st7735 = ST7735.ST7735( +st7735 = st7735.ST7735( port=0, cs=1, - dc=9, - backlight=12, + dc="GPIO9", + backlight="GPIO12", rotation=270, spi_speed_hz=10000000 ) @@ -44,10 +57,10 @@ HEIGHT = st7735.height # Set up canvas and font -img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) draw = ImageDraw.Draw(img) -path = os.path.dirname(os.path.realpath(__file__)) -font = ImageFont.truetype(path + "/fonts/Asap/Asap-Bold.ttf", 20) +font_size = 20 +font = ImageFont.truetype(UserFont, font_size) message = "" @@ -60,23 +73,22 @@ 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 - colours = [(v - min(values[variable]) + 1) / (max(values[variable]) - - min(values[variable]) + 1) for v in values[variable]] + 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) - print(message) + message = f"{variable[:4]}: {data:.1f} {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)] + 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.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)) + 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) @@ -84,19 +96,20 @@ def display_text(variable, data, unit): # Get the temperature of the CPU for compensation def get_cpu_temperature(): - process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE) - output, _error = process.communicate() - return float(output[output.index('=') + 1:output.rindex("'")]) + 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 = 0.8 +factor = 2.25 -cpu_temps = [0] * 5 +cpu_temps = [get_cpu_temperature()] * 5 delay = 0.5 # Debounce the proximity tap -mode = 0 # The starting mode +mode = 0 # The starting mode last_page = 0 light = 1 @@ -130,78 +143,90 @@ def get_cpu_temperature(): # One mode for each variable if mode == 0: - variable = "temperature" - unit = "C" + # 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(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 1: - variable = "pressure" + # variable = "pressure" unit = "hPa" data = bme280.get_pressure() - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 2: - variable = "humidity" + # variable = "humidity" unit = "%" data = bme280.get_humidity() - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 3: - variable = "light" + # variable = "light" unit = "Lux" if proximity < 10: data = ltr559.get_lux() else: data = 1 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 4: - variable = "oxidised" + # variable = "oxidised" unit = "kO" data = gas.read_all() data = data.oxidising / 1000 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 5: - variable = "reduced" + # variable = "reduced" unit = "kO" data = gas.read_all() data = data.reducing / 1000 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 6: - variable = "nh3" + # variable = "nh3" unit = "kO" data = gas.read_all() data = data.nh3 / 1000 - display_text(variable, data, unit) + display_text(variables[mode], data, unit) if mode == 7: - variable = "pm1" + # variable = "pm1" unit = "ug/m3" - data = pms5003.read() - data = data.pm_ug_per_m3(1.0) - display_text(variable, data, unit) + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warning("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" + # variable = "pm25" unit = "ug/m3" - data = pms5003.read() - data = data.pm_ug_per_m3(2.5) - display_text(variable, data, unit) + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warning("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" + # variable = "pm10" unit = "ug/m3" - data = pms5003.read() - data = data.pm_ug_per_m3(10) - display_text(variable, data, unit) + try: + data = pms5003.read() + except pmsReadTimeoutError: + logging.warning("Failed to read PMS5003") + else: + data = float(data.pm_ug_per_m3(10)) + display_text(variables[mode], data, unit) # Exit cleanly except KeyboardInterrupt: diff --git a/examples/combined.py b/examples/combined.py new file mode 100755 index 00000000..79e5d621 --- /dev/null +++ b/examples/combined.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 + +import colorsys +import sys +import time + +import st7735 + +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +import logging +from subprocess import PIPE, Popen + +from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont +from pms5003 import PMS5003 +from pms5003 import ReadTimeoutError as pmsReadTimeoutError +from pms5003 import SerialTimeoutError + +from enviroplus import gas + +logging.basicConfig( + format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S") + +logging.info("""combined.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="GPIO9", + backlight="GPIO12", + 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 = f"{variable[:4]}: {data:.1f} {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 = f"{variable[:4]}: {data:.1f} {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 = f"{variable[:4]}: {data_value:.1f} {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 (SerialTimeoutError, pmsReadTimeoutError): + logging.warning("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 (SerialTimeoutError, pmsReadTimeoutError): + logging.warning("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 (SerialTimeoutError, pmsReadTimeoutError): + logging.warning("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 (SerialTimeoutError, pmsReadTimeoutError): + logging.warning("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 index 048eb804..fb692f07 100755 --- a/examples/compensated-temperature.py +++ b/examples/compensated-temperature.py @@ -1,15 +1,17 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import logging import time + from bme280 import BME280 -from subprocess import PIPE, Popen +from smbus2 import SMBus -try: - from smbus2 import SMBus -except ImportError: - from smbus import SMBus +logging.basicConfig( + format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S") -print("""compensated-temperature.py - Use the CPU temperature +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 @@ -24,17 +26,17 @@ # Get the temperature of the CPU for compensation def get_cpu_temperature(): - process = Popen(['vcgencmd', 'measure_temp'], stdout=PIPE) - output, _error = process.communicate() - output = output.decode() - return float(output[output.index('=') + 1:output.rindex("'")]) + 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 = 0.8 +factor = 2.25 -cpu_temps = [0] * 5 +cpu_temps = [get_cpu_temperature()] * 5 while True: cpu_temp = get_cpu_temperature() @@ -43,5 +45,5 @@ def get_cpu_temperature(): 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) - print("Compensated temperature: {:05.2f} *C".format(comp_temp)) + logging.info(f"Compensated temperature: {comp_temp:05.2f} °C") time.sleep(1.0) diff --git a/examples/fonts/Asap/Asap-Bold.ttf b/examples/fonts/Asap/Asap-Bold.ttf deleted file mode 100644 index 52a14e54..00000000 Binary files a/examples/fonts/Asap/Asap-Bold.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-BoldItalic.ttf b/examples/fonts/Asap/Asap-BoldItalic.ttf deleted file mode 100644 index df290231..00000000 Binary files a/examples/fonts/Asap/Asap-BoldItalic.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-Italic.ttf b/examples/fonts/Asap/Asap-Italic.ttf deleted file mode 100644 index b07a0bc0..00000000 Binary files a/examples/fonts/Asap/Asap-Italic.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-Medium.ttf b/examples/fonts/Asap/Asap-Medium.ttf deleted file mode 100644 index 81ef310c..00000000 Binary files a/examples/fonts/Asap/Asap-Medium.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-MediumItalic.ttf b/examples/fonts/Asap/Asap-MediumItalic.ttf deleted file mode 100644 index 6f8d9060..00000000 Binary files a/examples/fonts/Asap/Asap-MediumItalic.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-Regular.ttf b/examples/fonts/Asap/Asap-Regular.ttf deleted file mode 100644 index af001969..00000000 Binary files a/examples/fonts/Asap/Asap-Regular.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-SemiBold.ttf b/examples/fonts/Asap/Asap-SemiBold.ttf deleted file mode 100644 index 5328f3f6..00000000 Binary files a/examples/fonts/Asap/Asap-SemiBold.ttf and /dev/null differ diff --git a/examples/fonts/Asap/Asap-SemiBoldItalic.ttf b/examples/fonts/Asap/Asap-SemiBoldItalic.ttf deleted file mode 100644 index 6415ef2b..00000000 Binary files a/examples/fonts/Asap/Asap-SemiBoldItalic.ttf and /dev/null differ diff --git a/examples/fonts/Asap/OFL.txt b/examples/fonts/Asap/OFL.txt deleted file mode 100644 index ad56d30d..00000000 --- a/examples/fonts/Asap/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright 2016 The Asap Project Authors (omnibus.type@gmail.com) - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/gas.py b/examples/gas.py index faf6eac8..c5fce5fc 100755 --- a/examples/gas.py +++ b/examples/gas.py @@ -1,9 +1,16 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import logging import time + from enviroplus import gas -print("""gas.py - Print readings from the MICS6814 Gas sensor. +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! @@ -12,7 +19,7 @@ try: while True: readings = gas.read_all() - print(readings) + 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 00000000..5697a81b 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 00000000..a91e24b5 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 00000000..a91e24b5 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 00000000..f8fb7913 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 00000000..5a7201c6 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 00000000..ba450db0 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 00000000..5a7201c6 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 00000000..54a826ff 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 00000000..21215b78 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 00000000..23029264 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 00000000..23029264 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 00000000..a7dea2f0 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 00000000..20172459 Binary files /dev/null and b/examples/icons/weather-storm.png differ diff --git a/examples/lcd.py b/examples/lcd.py index aa0e1936..97c2be44 100755 --- a/examples/lcd.py +++ b/examples/lcd.py @@ -1,20 +1,28 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 -import ST7735 +import logging + +import st7735 +from fonts.ttf import RobotoMedium as UserFont from PIL import Image, ImageDraw, ImageFont -print("""lcd.py - Hello, World! example on the 0.96" LCD. +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( +disp = st7735.ST7735( port=0, cs=1, - dc=9, - backlight=12, + dc="GPIO9", + backlight="GPIO12", rotation=270, spi_speed_hz=10000000 ) @@ -27,17 +35,20 @@ HEIGHT = disp.height # New canvas to draw on. -img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) draw = ImageDraw.Draw(img) # Text settings. font_size = 25 -font = ImageFont.truetype("fonts/Asap/Asap-Bold.ttf", font_size) +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) + +x1, y1, x2, y2 = font.getbbox(message) +size_x = x2 - x1 +size_y = y2 - y1 # Calculate text position x = (WIDTH - size_x) / 2 diff --git a/examples/light.py b/examples/light.py index 216477a7..70414db3 100755 --- a/examples/light.py +++ b/examples/light.py @@ -1,9 +1,22 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import logging import time -import ltr559 -print("""light.py - Print readings from the LTR559 Light & Proximity sensor. +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! @@ -13,9 +26,9 @@ while True: lux = ltr559.get_lux() prox = ltr559.get_proximity() - print("""Light: {:05.02f} Lux -Proximity: {:05.02f} -""".format(lux, prox)) + logging.info(f"""Light: {lux:05.02f} Lux +Proximity: {prox:05.02f} +""") time.sleep(1.0) except KeyboardInterrupt: pass diff --git a/examples/luftdaten.py b/examples/luftdaten.py deleted file mode 100755 index 27478c59..00000000 --- a/examples/luftdaten.py +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env python - -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 - -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) - 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")) - - resp_1 = requests.post("https://api.luftdaten.info/v1/push-sensor-data/", - json={ - "software_version": "enviro-plus 0.0.1", - "sensordatavalues": [{"value_type": key, "value": val} for - key, val in pm_values.items()] - }, - 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": [{"value_type": key, "value": val} for - key, val in temp_values.items()] - }, - 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 = 1.2 - -# 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("fonts/Asap/Asap-Bold.ttf", 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/mqtt-all.py b/examples/mqtt-all.py new file mode 100755 index 00000000..b4fd19ef --- /dev/null +++ b/examples/mqtt-all.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +Run mqtt broker on localhost: sudo apt-get install mosquitto mosquitto-clients + +Example run: python3 mqtt-all.py --broker 192.168.1.164 --topic enviro --username xxx --password xxxx +""" + +import argparse +import ssl +import time + +import st7735 +from bme280 import BME280 +from pms5003 import PMS5003, ReadTimeoutError, SerialTimeoutError + +from enviroplus import gas + +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + + ltr559 = LTR559() +except ImportError: + import ltr559 + +import json +from subprocess import PIPE, Popen, check_output + +import paho.mqtt.client as mqtt +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont + +try: + from smbus2 import SMBus +except ImportError: + from smbus import SMBus + + +DEFAULT_MQTT_BROKER_IP = "localhost" +DEFAULT_MQTT_BROKER_PORT = 1883 +DEFAULT_MQTT_TOPIC = "enviroplus" +DEFAULT_READ_INTERVAL = 5 +DEFAULT_TLS_MODE = False +DEFAULT_USERNAME = None +DEFAULT_PASSWORD = None + + +# mqtt callbacks +def on_connect(client, userdata, flags, rc): + if rc == 0: + print("connected OK") + else: + print("Bad connection Returned code=", rc) + + +def on_publish(client, userdata, mid): + print("mid: " + str(mid)) + + +# Read values from BME280 and return as dict +def read_bme280(bme280): + # Compensation factor for temperature + comp_factor = 2.25 + values = {} + cpu_temp = get_cpu_temperature() + raw_temp = bme280.get_temperature() # float + comp_temp = raw_temp - ((cpu_temp - raw_temp) / comp_factor) + values["temperature"] = int(comp_temp) + values["pressure"] = round( + int(bme280.get_pressure() * 100), -1 + ) # round to nearest 10 + values["humidity"] = int(bme280.get_humidity()) + data = gas.read_all() + values["oxidised"] = int(data.oxidising / 1000) + values["reduced"] = int(data.reducing / 1000) + values["nh3"] = int(data.nh3 / 1000) + values["lux"] = int(ltr559.get_lux()) + return values + + +# Read values PMS5003 and return as dict +def read_pms5003(pms5003): + values = {} + try: + pm_values = pms5003.read() # int + values["pm1"] = pm_values.pm_ug_per_m3(1) + values["pm25"] = pm_values.pm_ug_per_m3(2.5) + values["pm10"] = pm_values.pm_ug_per_m3(10) + except ReadTimeoutError: + pms5003.reset() + pm_values = pms5003.read() + values["pm1"] = pm_values.pm_ug_per_m3(1) + values["pm25"] = pm_values.pm_ug_per_m3(2.5) + values["pm10"] = 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(disp, mqtt_broker): + # Width and height to calculate text position + WIDTH = disp.width + HEIGHT = disp.height + # Text settings + font_size = 12 + font = ImageFont.truetype(UserFont, font_size) + + 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) + device_serial_number = get_serial_number() + message = f"Serial: {device_serial_number}\nWi-Fi: {wifi_status}\nmqtt-broker: {mqtt_broker}" + img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) + draw = ImageDraw.Draw(img) + x1, y1, x2, y2 = draw.textbbox((0,0), message, font=font) + size_x = x2 - x1 + size_y = y2 - y1 + 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 main(): + parser = argparse.ArgumentParser( + description="Publish enviroplus values over mqtt" + ) + parser.add_argument( + "--broker", + default=DEFAULT_MQTT_BROKER_IP, + type=str, + help="mqtt broker IP", + ) + parser.add_argument( + "--port", + default=DEFAULT_MQTT_BROKER_PORT, + type=int, + help="mqtt broker port", + ) + parser.add_argument( + "--topic", default=DEFAULT_MQTT_TOPIC, type=str, help="mqtt topic" + ) + parser.add_argument( + "--interval", + default=DEFAULT_READ_INTERVAL, + type=int, + help="the read interval in seconds", + ) + parser.add_argument( + "--tls", + default=DEFAULT_TLS_MODE, + action="store_true", + help="enable TLS" + ) + parser.add_argument( + "--username", + default=DEFAULT_USERNAME, + type=str, + help="mqtt username" + ) + parser.add_argument( + "--password", + default=DEFAULT_PASSWORD, + type=str, + help="mqtt password" + ) + args = parser.parse_args() + + # Raspberry Pi ID + device_serial_number = get_serial_number() + device_id = "raspi-" + device_serial_number + + print( + f"""mqtt-all.py - Reads Enviro plus data and sends over mqtt. + + broker: {args.broker} + client_id: {device_id} + port: {args.port} + topic: {args.topic} + tls: {args.tls} + username: {args.username} + password: {args.password} + + Press Ctrl+C to exit! + + """ + ) + + mqtt_client = mqtt.Client(client_id=device_id) + if args.username and args.password: + mqtt_client.username_pw_set(args.username, args.password) + mqtt_client.on_connect = on_connect + mqtt_client.on_publish = on_publish + + if args.tls is True: + mqtt_client.tls_set(tls_version=ssl.PROTOCOL_TLSv1_2) + + if args.username is not None: + mqtt_client.username_pw_set(args.username, password=args.password) + + mqtt_client.connect(args.broker, port=args.port) + + bus = SMBus(1) + + # Create BME280 instance + bme280 = BME280(i2c_dev=bus) + + # Create LCD instance + disp = st7735.ST7735( + port=0, + cs=1, + dc="GPIO9", + backlight="GPIO12", + rotation=270, + spi_speed_hz=10000000 + ) + + # Initialize display + disp.begin() + + # Try to create PMS5003 instance + HAS_PMS = False + try: + pms5003 = PMS5003() + _ = pms5003.read() + HAS_PMS = True + print("PMS5003 sensor is connected") + except SerialTimeoutError: + print("No PMS5003 sensor connected") + + # Display Raspberry Pi serial and Wi-Fi status + print(f"RPi serial: {device_serial_number}") + wifi_status = "connected" if check_wifi() else "disconnected" + print(f"Wi-Fi: {wifi_status}\n") + print(f"MQTT broker IP: {args.broker}") + + # Set an initial update time + update_time = time.time() + + # Main loop to read data, display, and send over mqtt + mqtt_client.loop_start() + while True: + try: + values = read_bme280(bme280) + if HAS_PMS: + pms_values = read_pms5003(pms5003) + values.update(pms_values) + time_since_update = time.time() - update_time + if time_since_update >= args.interval: + update_time = time.time() + values["serial"] = device_serial_number + print(values) + mqtt_client.publish(args.topic, json.dumps(values), retain=True) + display_status(disp, args.broker) + except Exception as e: + print(e) + + +if __name__ == "__main__": + main() diff --git a/examples/noise-amps-at-freqs.py b/examples/noise-amps-at-freqs.py new file mode 100755 index 00000000..75497df2 --- /dev/null +++ b/examples/noise-amps-at-freqs.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +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=1, + dc="GPIO9", + backlight="GPIO12", + rotation=270, + spi_speed_hz=10000000 +) + +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 100755 index 00000000..480815e6 --- /dev/null +++ b/examples/noise-profile.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 + +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=1, + dc="GPIO9", + backlight="GPIO12", + rotation=270, + spi_speed_hz=10000000 +) + +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 index e71ca5d9..6ecaf707 100755 --- a/examples/particulates.py +++ b/examples/particulates.py @@ -1,9 +1,16 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import logging import time + from pms5003 import PMS5003, ReadTimeoutError -print("""particulates.py - Print readings from the PMS5003 particulate sensor. +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! @@ -16,8 +23,7 @@ while True: try: readings = pms5003.read() - print(readings) - time.sleep(1.0) + logging.info(readings) except ReadTimeoutError: pms5003 = PMS5003() except KeyboardInterrupt: diff --git a/examples/sensorcommunity.py b/examples/sensorcommunity.py new file mode 100755 index 00000000..cdd3a480 --- /dev/null +++ b/examples/sensorcommunity.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 + +import logging +import time +from subprocess import check_output + +import requests +import st7735 +from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont +from pms5003 import PMS5003, ChecksumMismatchError, ReadTimeoutError +from smbus2 import SMBus + +logging.basicConfig( + format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S") + +logging.info("""sensorcommunity.py - Reads temperature, pressure, humidity, +PM2.5, and PM10 from Enviro plus and sends data to Sensor.Community, +the citizen science air quality project. + +Note: you'll need to register with Sensor.Community at: +https://devices.sensor.community/ 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 +Sensor.Community 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="GPIO9", + backlight="GPIO12", + 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"] = f"{comp_temp:.2f}" + values["pressure"] = f"{bme280.get_pressure() * 100:.2f}" + values["humidity"] = f"{bme280.get_humidity():.2f}" + 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, ChecksumMismatchError): + logging.info("Failed to read PMS5003. Resetting and retrying.") + 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 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 + + +# 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.startswith("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 = f"{id}\nWi-Fi: {wifi_status}" + img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) + draw = ImageDraw.Draw(img) + x1, y1, x2, y2 = font.getbbox(message) + size_x = x2 - x1 + size_y = y2 - y1 + 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_sensorcommunity(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_pm = None + resp_bmp = None + + try: + resp_pm = requests.post( + "https://api.sensor.community/v1/push-sensor-data/", + json={ + "software_version": "enviro-plus 1.0.0", + "sensordatavalues": pm_values_json + }, + headers={ + "X-PIN": "1", + "X-Sensor": id, + "Content-Type": "application/json", + "cache-control": "no-cache" + }, + timeout=5 + ) + except requests.exceptions.ConnectionError as e: + logging.warning(f"Sensor.Community PM Connection Error: {e}") + except requests.exceptions.Timeout as e: + logging.warning(f"Sensor.Community PM Timeout Error: {e}") + except requests.exceptions.RequestException as e: + logging.warning(f"Sensor.Community PM Request Error: {e}") + + try: + resp_bmp = requests.post( + "https://api.sensor.community/v1/push-sensor-data/", + json={ + "software_version": "enviro-plus 1.0.0", + "sensordatavalues": temp_values_json + }, + headers={ + "X-PIN": "11", + "X-Sensor": id, + "Content-Type": "application/json", + "cache-control": "no-cache" + }, + timeout=5 + ) + except requests.exceptions.ConnectionError as e: + logging.warning(f"Sensor.Community Climate Connection Error: {e}") + except requests.exceptions.Timeout as e: + logging.warning(f"Sensor.Community Climate Timeout Error: {e}") + except requests.exceptions.RequestException as e: + logging.warning(f"Sensor.Community Climate Request Error: {e}") + + if resp_pm is not None and resp_bmp is not None: + if resp_pm.ok and resp_bmp.ok: + return True + else: + logging.warning(f"Sensor.Community Error. PM: {resp_pm.reason}, Climate: {resp_bmp.reason}") + return False + else: + return False + + +# Compensation factor for temperature +comp_factor = 2.25 + +# Raspberry Pi ID to send to Sensor.Community +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) + +# Log Raspberry Pi serial and Wi-Fi status +logging.info(f"Raspberry Pi serial: {get_serial_number()}") +wifi_status = "connected" if check_wifi() else "disconnected" +logging.info(f"Wi-Fi: {wifi_status}\n") + +time_since_update = 0 +update_time = time.time() + +# Main loop to read data, display, and send to Sensor.Community +while True: + try: + values = read_values() + time_since_update = time.time() - update_time + if time_since_update > 145: + logging.info(values) + update_time = time.time() + if send_to_sensorcommunity(values, id): + logging.info("Sensor.Community Response: OK") + else: + logging.warning("Sensor.Community Response: Failed") + display_status() + except Exception as e: + logging.warning(f"Main Loop Exception: {e}") diff --git a/examples/sensorcommunity_combined.py b/examples/sensorcommunity_combined.py new file mode 100644 index 00000000..bb9869d6 --- /dev/null +++ b/examples/sensorcommunity_combined.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 + +import colorsys +import logging +import time +from subprocess import PIPE, Popen, check_output + +import requests +import st7735 +from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from PIL import Image, ImageDraw, ImageFont +from pms5003 import PMS5003, ReadTimeoutError +from smbus2 import SMBus + +from enviroplus import gas + +try: + # Transitional fix for breaking change in LTR559 + from ltr559 import LTR559 + ltr559 = LTR559() +except ImportError: + import ltr559 + +print("""sensorcommunity_combined.py - This combines the functionality of sensorcommunity.py and combined.py +================================================================================================ +Sensor.Community INFO +Reads temperature, pressure, humidity, +PM2.5, and PM10 from Enviro plus and sends data to Sensor.Community, +the citizen science air quality project. + +Note: you'll need to register with Sensor.Community at: +https://devices.sensor.community/ 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 +Sensor.Community map. + +Press Ctrl+C to exit! + +======================================================================== + +Combined INFO: +Displays readings from all of Enviro plus' sensors + +Press Ctrl+C to exit! + +""") + +logging.basicConfig( + format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S") + +logging.info(""" """) +bus = SMBus(1) + +# Create BME280 instance +bme280 = BME280(i2c_dev=bus) + + +# Create PMS5003 instance +pms5003 = PMS5003() + +# 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, 25, 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_lcd = {} + + +# Read values from BME280 and PMS5003 and return as dict +def read_values(comp_temp, mod_press, raw_humid, raw_pm25, raw_pm10): + values = {} + values["temperature"] = f"{comp_temp:.2f}" + values["pressure"] = f"{mod_press:.2f}" + values["humidity"] = f"{raw_humid:.2f}" + values["P2"] = str(raw_pm25) + values["P1"] = str(raw_pm10) + 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.startswith("Serial"): + return line.split(":")[1].strip() + + +# Check for Wi-Fi connection +def check_wifi(): + if check_output(["hostname", "-I"]): + return True + else: + return False + + +# Create ST7735 LCD display class +st7735 = st7735.ST7735( + port=0, + cs=1, + dc="GPIO9", + backlight="GPIO12", + 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 + +# 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_lcd[variable] = values_lcd[variable][1:] + [data] + unit = units[idx] + message = f"{variable[:4]}: {data:.1f} {unit}" + logging.info(message) + + +# Displays data and text on the 0.96" LCD +def display_text(variable, data, unit): + # Maintain length of list + values_lcd[variable] = values_lcd[variable][1:] + [data] + # Scale the values for the variable between 0 and 1 + vmin = min(values_lcd[variable]) + vmax = max(values_lcd[variable]) + colours = [(v - vmin + 1) / (vmax - vmin + 1) + for v in values_lcd[variable]] + # Format the variable name and value + message = f"{variable[:4]}: {data:.1f} {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) + +# 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_lcd[variable][-1] + unit = units[i] + x = x_offset + ((WIDTH // column_count) * (i // row_count)) + y = y_offset + ((HEIGHT / row_count) * (i % row_count)) + message = f"{variable[:4]}: {data_value:.1f} {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) + + +def send_to_sensorcommunity(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.sensor.community/v1/push-sensor-data/", + json={ + "software_version": "enviro-plus 1.0.0", + "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.sensor.community/v1/push-sensor-data/", + json={ + "software_version": "enviro-plus 1.0.0", + "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 = 1 + +# Raspberry Pi ID to send to Sensor.Community +id = "raspi-" + get_serial_number() + + +# Added for state +delay = 0.5 # Debounce the proximity tap +mode = 10 # The starting mode +last_page = 0 +light = 1 + + +for v in variables: + values_lcd[v] = [1] * WIDTH + + +# Text settings +font_size = 16 +font = ImageFont.truetype(UserFont, font_size) +cpu_temps = [get_cpu_temperature()] * 5 + +# Display Raspberry Pi serial and Wi-Fi status +print(f"Raspberry Pi serial: {get_serial_number()}") +wifi_status = "connected" if check_wifi() else "disconnected" +print(f"Wi-Fi: {wifi_status}\n") + +time_since_update = 0 +update_time = time.time() +cpu_temps_len = float(len(cpu_temps)) + +# Main loop to read data, display, and send to Sensor.Community +while True: + try: + curtime = time.time() + time_since_update = curtime - update_time + + # Calculate these things once, not twice + 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) / cpu_temps_len + raw_temp = bme280.get_temperature() + comp_temp = raw_temp - ((avg_cpu_temp - raw_temp) / comp_factor) + + raw_press = bme280.get_pressure() + raw_humid = bme280.get_humidity() + try: + pm_values = pms5003.read() + raw_pm25 = pm_values.pm_ug_per_m3(2.5) + raw_pm10 = pm_values.pm_ug_per_m3(10) + except ReadTimeoutError: + pms5003.reset() + pm_values = pms5003.read() + raw_pm25 = pm_values.pm_ug_per_m3(2.5) + raw_pm10 = pm_values.pm_ug_per_m3(10) + + if time_since_update > 145: + values = read_values(comp_temp, raw_press*100, + raw_humid, raw_pm25, raw_pm10) + resp = send_to_sensorcommunity(values, id) + update_time = curtime + status = "ok" if resp else "failed" + print(f"Response: {status}\n") + + # Now comes the combined.py functionality: + # If the proximity crosses the threshold, toggle the mode + proximity = ltr559.get_proximity() + if proximity > 1500 and curtime - last_page > delay: + mode = (mode + 1) % 11 + last_page = curtime + # One mode for each variable + if mode == 0: + # variable = "temperature" + unit = "C" + display_text(variables[mode], comp_temp, unit) + + if mode == 1: + # variable = "pressure" + unit = "hPa" + display_text(variables[mode], raw_press, unit) + + if mode == 2: + # variable = "humidity" + unit = "%" + display_text(variables[mode], raw_humid, 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" + data = float(pm_values.pm_ug_per_m3(1.0)) + display_text(variables[mode], data, unit) + + if mode == 8: + # variable = "pm25" + unit = "ug/m3" + display_text(variables[mode], float(raw_pm25), unit) + + if mode == 9: + # variable = "pm10" + unit = "ug/m3" + display_text(variables[mode], float(raw_pm10), unit) + + if mode == 10: + # Everything on one screen + save_data(0, comp_temp) + save_data(1, raw_press) + display_everything() + save_data(2, raw_humid) + 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 + save_data(7, float(pm_values.pm_ug_per_m3(1.0))) + save_data(8, float(raw_pm25)) + save_data(9, float(raw_pm10)) + display_everything() + except Exception as e: + print(e) diff --git a/examples/weather-and-light.py b/examples/weather-and-light.py new file mode 100755 index 00000000..04f1bd8f --- /dev/null +++ b/examples/weather-and-light.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import colorsys +import os +import time +from datetime import datetime, timedelta + +import numpy +import pytz +import st7735 +from astral.geocoder import database, lookup +from astral.sun import sun +from bme280 import BME280 +from fonts.ttf import RobotoMedium as UserFont +from ltr559 import LTR559 +from PIL import Image, ImageDraw, ImageFilter, ImageFont +from smbus2 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 text_size(font, text): + x1, y1, x2, y2 = font.getbbox(text) + return x2 - x1, y2 - y1 + + +def overlay_text(img, position, text, font, align_right=False, rectangle=False): + draw = ImageDraw.Draw(img) + w, h = text_size(font, 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="GPIO9", + backlight="GPIO12", + 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) + _, text_height = text_size(font_lg, temp_string) + spacing = text_height + 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) + _, text_height = text_size(font_lg, humidity_string) + spacing = text_height + 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) + _, text_height = text_size(font_lg, light_string.replace(",", "")) + spacing = text_height + 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() + _, text_height = text_size(font_lg, pressure_string.replace(",", "")) + spacing = text_height + 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 index 17aba204..0b671d31 100755 --- a/examples/weather.py +++ b/examples/weather.py @@ -1,14 +1,17 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 +import logging import time + from bme280 import BME280 +from smbus2 import SMBus -try: - from smbus2 import SMBus -except ImportError: - from smbus import SMBus +logging.basicConfig( + format="%(asctime)s.%(msecs)03d %(levelname)-8s %(message)s", + level=logging.INFO, + datefmt="%Y-%m-%d %H:%M:%S") -print("""weather.py - Print readings from the BME280 weather sensor. +logging.info("""weather.py - Print readings from the BME280 weather sensor. Press Ctrl+C to exit! @@ -21,8 +24,8 @@ temperature = bme280.get_temperature() pressure = bme280.get_pressure() humidity = bme280.get_humidity() - print("""Temperature: {:05.2f} *C -Pressure: {:05.2f} hPa -Relative humidity: {:05.2f} % -""".format(temperature, pressure, humidity)) + logging.info(f"""Temperature: {temperature:05.2f} °C +Pressure: {pressure:05.2f} hPa +Relative humidity: {humidity:05.2f} % +""") time.sleep(1) diff --git a/install.sh b/install.sh index 1fec5c84..d2d973a9 100755 --- a/install.sh +++ b/install.sh @@ -1,22 +1,29 @@ #!/bin/bash - -CONFIG=/boot/config.txt -DATESTAMP=`date "+%Y-%M-%d-%H-%M-%S"` +LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') +CONFIG_FILE=config.txt +CONFIG_DIR="/boot/firmware" +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` +RESOURCES_TOP_DIR="$HOME/Pimoroni" +VENV_BASH_SNIPPET="$RESOURCES_TOP_DIR/auto_venv.sh" +VENV_DIR="$HOME/.virtualenvs/pimoroni" +USAGE="./install.sh (--unstable)" +POSITIONAL_ARGS=() +FORCE=false +UNSTABLE=false +PYTHON="python" +CMD_ERRORS=false + user_check() { - if [ $(id -u) -ne 0 ]; then - printf "Script must be run as root. Try 'sudo ./install.sh'\n" - exit 1 + if [ "$(id -u)" -eq 0 ]; then + fatal "Script should not be run as root. Try './install.sh'\n" fi } confirm() { - if [ "$FORCE" == '-y' ]; then + if $FORCE; then true else read -r -p "$1 [y/N] " response < /dev/tty @@ -28,15 +35,6 @@ confirm() { 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)" } @@ -46,148 +44,342 @@ inform() { } warning() { - echo -e "$(tput setaf 1)$1$(tput sgr0)" + echo -e "$(tput setaf 1)⚠ WARNING:$(tput sgr0) $1" +} + +fatal() { + echo -e "$(tput setaf 1)⚠ FATAL:$(tput sgr0) $1" + exit 1 +} + +find_config() { + if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then + CONFIG_DIR="/boot" + if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then + fatal "Could not find $CONFIG_FILE!" + fi + fi + inform "Using $CONFIG_FILE in $CONFIG_DIR" +} + +venv_bash_snippet() { + inform "Checking for $VENV_BASH_SNIPPET\n" + if [ ! -f "$VENV_BASH_SNIPPET" ]; then + inform "Creating $VENV_BASH_SNIPPET\n" + mkdir -p "$RESOURCES_TOP_DIR" + cat << EOF > "$VENV_BASH_SNIPPET" +# Add "source $VENV_BASH_SNIPPET" to your ~/.bashrc to activate +# the Pimoroni virtual environment automagically! +VENV_DIR="$VENV_DIR" +if [ ! -f \$VENV_DIR/bin/activate ]; then + printf "Creating user Python environment in \$VENV_DIR, please wait...\n" + mkdir -p \$VENV_DIR + python3 -m venv --system-site-packages \$VENV_DIR +fi +printf " ↓ ↓ ↓ ↓ Hello, we've activated a Python venv for you. To exit, type \"deactivate\".\n" +source \$VENV_DIR/bin/activate +EOF + fi +} + +venv_check() { + PYTHON_BIN=$(which "$PYTHON") + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + if confirm "Would you like us to create and/or use a default one?"; then + printf "\n" + if [ ! -f "$VENV_DIR/bin/activate" ]; then + inform "Creating a new virtual Python environment in $VENV_DIR, please wait...\n" + mkdir -p "$VENV_DIR" + /usr/bin/python3 -m venv "$VENV_DIR" --system-site-packages + venv_bash_snippet + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + else + inform "Activating existing virtual Python environment in $VENV_DIR\n" + printf "source \"%s/bin/activate\"\n" "$VENV_DIR" + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + fi + else + printf "\n" + fatal "Please create and/or activate a virtual Python environment and try again!\n" + fi + fi + printf "\n" +} + +check_for_error() { + if [ $? -ne 0 ]; then + CMD_ERRORS=true + warning "^^^ 😬 previous command did not exit cleanly!" + fi } 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 + inform "Backing up $CONFIG_DIR/$CONFIG_FILE to $CONFIG_DIR/$FILENAME\n" + sudo cp "$CONFIG_DIR/$CONFIG_FILE" "$CONFIG_DIR/$FILENAME" + mkdir -p "$RESOURCES_TOP_DIR/config-backups/" + cp $CONFIG_DIR/$CONFIG_FILE "$RESOURCES_TOP_DIR/config-backups/$FILENAME" if [ -f "$UNINSTALLER" ]; then - echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG" >> $UNINSTALLER + echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG_DIR/$CONFIG_FILE" >> "$UNINSTALLER" fi fi } function apt_pkg_install { - PACKAGES=() + PACKAGES_NEEDED=() PACKAGES_IN=("$@") + # Check the list of packages and only run update/install if we need to 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 [ "$PACKAGE" == "" ]; then continue; fi + printf "Checking for %s\n" "$PACKAGE" + dpkg -L "$PACKAGE" > /dev/null 2>&1 if [ "$?" == "1" ]; then - PACKAGES+=("$PACKAGE") + PACKAGES_NEEDED+=("$PACKAGE") fi done - PACKAGES="${PACKAGES[@]}" + PACKAGES="${PACKAGES_NEEDED[*]}" if ! [ "$PACKAGES" == "" ]; then - echo "Installing missing packages: $PACKAGES" + printf "\n" + inform "Installing missing packages: $PACKAGES" if [ ! $APT_HAS_UPDATED ]; then - apt update + sudo apt update APT_HAS_UPDATED=true fi - apt install -y $PACKAGES + # shellcheck disable=SC2086 + sudo apt install -y $PACKAGES + check_for_error if [ -f "$UNINSTALLER" ]; then - echo "apt uninstall -y $PACKAGES" + echo "apt uninstall -y $PACKAGES" >> "$UNINSTALLER" fi fi } +function pip_pkg_install { + # A null Keyring prevents pip stalling in the background + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --prefer-binary --upgrade "$@" + check_for_error +} + +function pip_requirements_install { + # A null Keyring prevents pip stalling in the background + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --prefer-binary -r "$@" + check_for_error +} + +while [[ $# -gt 0 ]]; do + K="$1" + case $K in + -u|--unstable) + UNSTABLE=true + shift + ;; + -f|--force) + FORCE=true + shift + ;; + -p|--python) + PYTHON=$2 + shift + shift + ;; + *) + if [[ $1 == -* ]]; then + printf "Unrecognised option: %s\n" "$1"; + printf "Usage: %s\n" "$USAGE"; + exit 1 + fi + POSITIONAL_ARGS+=("$1") + shift + esac +done + +printf "Installing %s...\n\n" "$LIBRARY_NAME" + user_check +venv_check -apt_pkg_install python-configparser - -CONFIG_VARS=`python - < $UNINSTALLER +mkdir -p "$RESOURCES_DIR" + +# Create a stub uninstaller file, we'll try to add the inverse of every +# install command run to here, though it's not complete. +cat << EOF > "$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 +source $VIRTUAL_ENV/bin/activate EOF -printf "$LIBRARY_NAME $LIBRARY_VERSION Python Library: Installer\n\n" +printf "\n" + +inform "Installing for $PYTHON_VER...\n" -cd library +# Install apt packages from pyproject.toml / tool.pimoroni.apt_packages +apt_pkg_install "${APT_PACKAGES[@]}" -printf "Installing for Python 2..\n" -apt_pkg_install "${PY2_DEPS[@]}" -python setup.py install > /dev/null +printf "\n" + +if $UNSTABLE; then + warning "Installing unstable library from source.\n" + pip_pkg_install . +else + inform "Installing stable library from pypi.\n" + pip_pkg_install "$LIBRARY_NAME" +fi + +# shellcheck disable=SC2181 # One of two commands run, depending on --unstable flag if [ $? -eq 0 ]; then success "Done!\n" - echo "pip uninstall $LIBRARY_NAME" >> $UNINSTALLER + echo "$PYTHON -m 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 +find_config -cd $WD +printf "\n" +# Run the setup commands from pyproject.toml / tool.pimoroni.commands + +inform "Running setup commands...\n" 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 + # Attempt to catch anything that touches config.txt and trigger a backup + if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG_DIR/$CONFIG_FILE"* ]] || [[ "$CMD" == *"\$CONFIG_DIR/\$CONFIG_FILE"* ]]; then do_config_backup fi - eval $CMD + if [[ ! "$CMD" == printf* ]]; then + printf "Running: \"%s\"\n" "$CMD" + fi + eval "$CMD" + check_for_error done +printf "\n" + +# Add the config.txt entries from pyproject.toml / tool.pimoroni.configtxt + 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 + inform "Adding $CONFIG_LINE to $CONFIG_DIR/$CONFIG_FILE" + sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG_DIR/$CONFIG_FILE + if ! grep -q "^$CONFIG_LINE" $CONFIG_DIR/$CONFIG_FILE; then + printf "%s \n" "$CONFIG_LINE" | sudo tee --append $CONFIG_DIR/$CONFIG_FILE fi fi done +printf "\n" + +# Just a straight copy of the examples/ dir into ~/Pimoroni/board/examples + 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 + cp -r examples/ "$RESOURCES_DIR" + echo "rm -r $RESOURCES_DIR" >> "$UNINSTALLER" + success "Done!" + fi +fi + +printf "\n" + +if [ -f "requirements-examples.txt" ]; then + if confirm "Would you like to install example dependencies?"; then + inform "Installing dependencies from requirements-examples.txt..." + pip_requirements_install requirements-examples.txt + fi +fi + +printf "\n" + +# Use pdoc to generate basic documentation from the installed module + +if confirm "Would you like to generate documentation?"; then + inform "Installing pdoc. Please wait..." + pip_pkg_install pdoc + inform "Generating documentation.\n" + if $PYTHON -m pdoc "$LIBRARY_NAME" -o "$RESOURCES_DIR/docs" > /dev/null; then + inform "Documentation saved to $RESOURCES_DIR/docs" + success "Done!" + else + warning "Error: Failed to generate documentation." fi fi -success "\nAll done!" -inform "If this is your first time installing you should reboot for hardware changes to tkae effect.\n" -inform "Find uninstall steps in $UNINSTALLER\n" +printf "\n" + +if [ "$CMD_ERRORS" = true ]; then + warning "One or more setup commands appear to have failed." + printf "This might prevent things from working properly.\n" + printf "Make sure your OS is up to date and try re-running this installer.\n" + printf "If things still don't work, report this or find help at %s.\n\n" "$GITHUB_URL" +else + success "\nAll done!" +fi + +printf "If this is your first time installing you should reboot for hardware changes to take effect.\n" +printf "Find uninstall steps in %s\n\n" "$UNINSTALLER" + +if [ "$CMD_ERRORS" = true ]; then + exit 1 +else + exit 0 +fi diff --git a/library/CHANGELOG.txt b/library/CHANGELOG.txt deleted file mode 100644 index 0f98d125..00000000 --- a/library/CHANGELOG.txt +++ /dev/null @@ -1,4 +0,0 @@ -0.0.1 ------ - -* Initial Release diff --git a/library/LICENSE.txt b/library/LICENSE.txt deleted file mode 100644 index aed751a0..00000000 --- a/library/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -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/README.rst b/library/README.rst deleted file mode 100644 index f90e0720..00000000 --- a/library/README.rst +++ /dev/null @@ -1,30 +0,0 @@ -Enviro+ pHAT -============ - -`Build Status `__ -`Coverage -Status `__ -`PyPi Package `__ `Python -Versions `__ - -Installing -========== - -Stable library from PyPi: - -- Just run ``sudo pip install enviroplus`` - -(**Note** that you’re best using the git clone / install.sh method below -if you want all of the UART serial configuration for the PMS5003 -particulate matter sensor to run automatically) - -Latest/development library from GitHub: - -- ``git clone https://github.com/pimoroni/enviroplus-python`` -- ``cd enviroplus-python`` -- ``sudo ./install.sh`` - -0.0.1 ------ - -* Initial Release diff --git a/library/enviroplus/__init__.py b/library/enviroplus/__init__.py deleted file mode 100644 index b8023d8b..00000000 --- a/library/enviroplus/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = '0.0.1' diff --git a/library/enviroplus/noise.py b/library/enviroplus/noise.py deleted file mode 100644 index e69de29b..00000000 diff --git a/library/setup.cfg b/library/setup.cfg deleted file mode 100644 index ed7cef7f..00000000 --- a/library/setup.cfg +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -[metadata] -name = enviroplus -version = 0.0.1 -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.6 - Programming Language :: Python :: 2.7 - Programming Language :: Python :: 3 - Topic :: Software Development - Topic :: Software Development :: Libraries - Topic :: System :: Hardware - -[options] -install_requires = - pimoroni-bme280 - pms5003 - ltr559 - st7735 - ads1015 - -[flake8] -exclude = - .tox, - .eggs, - .git, - __pycache__, - build, - dist -ignore = - E501 - -[pimoroni] -py2deps = - python-pip - python-numpy - python-smbus - python-pil -py3deps = - python3-pip - python3-numpy - python3-smbus - python3-pil -configtxt = - dtoverlay=pi3-miniuart-bt -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 deleted file mode 100755 index 08ebdc56..00000000 --- a/library/setup.py +++ /dev/null @@ -1,36 +0,0 @@ -#!/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( - packages=['enviroplus'], - install_requires=['setuptools>={}'.format(minimum_version), 'pimoroni-bme280', 'pms5003', 'ltr559', 'st7735', 'ads1015'] -) diff --git a/library/tests/test_setup.py b/library/tests/test_setup.py deleted file mode 100644 index 7c25d946..00000000 --- a/library/tests/test_setup.py +++ /dev/null @@ -1,98 +0,0 @@ -import sys -import mock -from i2cdevice import MockSMBus - - -class SMBusFakeDevice(MockSMBus): - def __init__(self, i2c_bus): - MockSMBus.__init__(self, i2c_bus) - self.regs[0x00:0x01] = 0x0f, 0x00 - - -def test_gas_setup(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - smbus = mock.Mock() - smbus.SMBus = SMBusFakeDevice - sys.modules['smbus'] = smbus - from enviroplus import gas - gas._is_setup = False - gas.setup() - gas.setup() - - -def test_gas_read_all(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - smbus = mock.Mock() - smbus.SMBus = SMBusFakeDevice - sys.modules['smbus'] = 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(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - smbus = mock.Mock() - smbus.SMBus = SMBusFakeDevice - sys.modules['smbus'] = 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(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - smbus = mock.Mock() - smbus.SMBus = SMBusFakeDevice - sys.modules['smbus'] = 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(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - smbus = mock.Mock() - smbus.SMBus = SMBusFakeDevice - sys.modules['smbus'] = smbus - from enviroplus import gas - gas._is_setup = False - - gas.enable_adc(True) - assert gas.read_adc() == 0.255 - - -def test_gas_read_adc_str(): - sys.modules['RPi'] = mock.Mock() - sys.modules['RPi.GPIO'] = mock.Mock() - smbus = mock.Mock() - smbus.SMBus = SMBusFakeDevice - sys.modules['smbus'] = 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()) diff --git a/library/tox.ini b/library/tox.ini deleted file mode 100644 index aa962163..00000000 --- a/library/tox.ini +++ /dev/null @@ -1,24 +0,0 @@ -[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/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..057079f8 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,139 @@ +[build-system] +requires = ["hatchling", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[project] +name = "enviroplus" +dynamic = ["version", "readme"] +description = "Enviro pHAT Plus environmental monitoring add-on for Raspberry Pi" +license = {file = "LICENSE"} +requires-python = ">= 3.7" +authors = [ + { name = "Philip Howard", email = "phil@pimoroni.com" }, +] +maintainers = [ + { name = "Philip Howard", email = "phil@pimoroni.com" }, +] +keywords = [ + "Pi", + "Raspberry", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: System :: Hardware", +] +dependencies = [ + "gpiod >= 2.1.3", + "gpiodevice >= 0.0.3", + "pimoroni-bme280 >= 1.0.0", + "pms5003 >= 1.0.1", + "ltr559 >= 1.0.0", + "st7735 >= 1.0.0", + "ads1015 >= 1.0.0" +] + +[project.urls] +GitHub = "https://www.github.com/pimoroni/enviroplus-python" +Homepage = "https://www.pimoroni.com" + +[tool.hatch.version] +path = "enviroplus/__init__.py" + +[tool.hatch.build] +include = [ + "enviroplus", + "README.md", + "CHANGELOG.md", + "LICENSE" +] + +[tool.hatch.build.targets.sdist] +include = [ + "*" +] +exclude = [ + ".*", + "dist" +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" +fragments = [ + { path = "README.md" }, + { text = "\n" }, + { path = "CHANGELOG.md" } +] + +[tool.ruff] +exclude = [ + '.tox', + '.egg', + '.git', + '__pycache__', + 'build', + 'dist' +] +line-length = 200 + +[tool.codespell] +skip = """ +./.tox,\ +./.egg,\ +./.git,\ +./__pycache__,\ +./build,\ +./dist.\ +""" + +[tool.isort] +line_length = 200 + +[tool.black] +line-length = 200 + +[tool.check-manifest] +ignore = [ + '.stickler.yml', + 'boilerplate.md', + 'check.sh', + 'install.sh', + 'uninstall.sh', + 'Makefile', + 'tox.ini', + 'tests/*', + 'examples/*', + '.coveragerc', + 'requirements-dev.txt' +] + +[tool.pimoroni] +apt_packages = [ + "python3", + "python3-pip", + "python3-cffi", + "libportaudio2" +] +configtxt = [ + "dtoverlay=pi3-miniuart-bt", + "dtoverlay=adau7002-simple" +] +commands = [ + "printf \"Setting up i2c and SPI..\\n\"", + "sudo raspi-config nonint do_spi 0", + "sudo raspi-config nonint do_i2c 0", + "printf \"Setting up serial for PMS5003..\\n\"", + "sudo raspi-config nonint do_serial_cons 1", + "sudo raspi-config nonint do_serial_hw 0" +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..525b0427 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,9 @@ +check-manifest +ruff +codespell +isort +twine +hatch +hatch-fancy-pypi-readme +tox +pdoc diff --git a/requirements-examples.txt b/requirements-examples.txt new file mode 100644 index 00000000..96def717 --- /dev/null +++ b/requirements-examples.txt @@ -0,0 +1,7 @@ +fonts +font-roboto +astral +pytz +sounddevice +paho-mqtt +pillow diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..20237430 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,117 @@ +"""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 + + +class SMBusFakeDeviceNoTimeout(MockSMBus): + def __init__(self, i2c_bus): + MockSMBus.__init__(self, i2c_bus) + self.regs[0x00:0x01] = 0x0f, 0x80 + + +@pytest.fixture(scope="function", autouse=True) +def cleanup(): + yield None + modules = "enviroplus", "enviroplus.noise", "enviroplus.gas", "ads1015", "i2cdevice" + for module in modules: + try: + del sys.modules[module] + except KeyError: + pass + + +@pytest.fixture(scope="function", autouse=False) +def gpiod(): + sys.modules["gpiod"] = mock.Mock() + sys.modules["gpiod.line"] = mock.Mock() + yield sys.modules["gpiod"] + del sys.modules["gpiod.line"] + del sys.modules["gpiod"] + + +@pytest.fixture(scope="function", autouse=False) +def gpiodevice(): + gpiodevice = mock.Mock() + gpiodevice.get_pins_for_platform.return_value = [(mock.Mock(), 0)] + gpiodevice.get_pin.return_value = (mock.Mock(), 0) + + sys.modules["gpiodevice"] = gpiodevice + yield gpiodevice + del sys.modules["gpiodevice"] + + +@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 smbus2 module.""" + smbus = mock.MagicMock() + smbus.SMBus = SMBusFakeDevice + sys.modules["smbus2"] = smbus + yield smbus + del sys.modules["smbus2"] + + +@pytest.fixture(scope="function", autouse=False) +def smbus_notimeout(): + """Mock smbus2 module.""" + smbus = mock.MagicMock() + smbus.SMBus = SMBusFakeDeviceNoTimeout + sys.modules["smbus2"] = smbus + yield smbus + del sys.modules["smbus2"] + + +@pytest.fixture(scope="function", autouse=False) +def mocksmbus(): + """Mock smbus2 module.""" + smbus = mock.MagicMock() + sys.modules["smbus2"] = smbus + yield smbus + del sys.modules["smbus2"] + + +@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/tests/test_noise.py b/tests/test_noise.py new file mode 100644 index 00000000..a5eb7da0 --- /dev/null +++ b/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, device="adau7002", 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, device="adau7002", 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/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 00000000..fa7fb939 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,86 @@ +import pytest + + +def test_gas_setup(gpiod, gpiodevice, smbus): + from enviroplus import gas + gas._is_setup = False + gas.setup() + gas.setup() + + +def test_gas_unavailable(gpiod, gpiodevice, mocksmbus): + from enviroplus import gas + mocksmbus.SMBus(1).read_i2c_block_data.side_effect = IOError("Oh no!") + gas._is_setup = False + assert gas.available() is False + + with pytest.raises(RuntimeError): + gas.read_all() + + +def test_gas_available(gpiod, gpiodevice, smbus_notimeout): + from enviroplus import gas + gas._is_setup = False + assert gas.available() is True + + +def test_gas_read_all(gpiod, gpiodevice, smbus): + from enviroplus import gas + gas._is_setup = False + result = gas.read_all() + + assert isinstance(result.oxidising, float) + assert int(result.oxidising) == 16641 + + assert isinstance(result.reducing, float) + assert int(result.reducing) == 16727 + + assert isinstance(result.nh3, float) + assert int(result.nh3) == 16813 + + assert "Oxidising" in str(result) + + +def test_gas_read_each(gpiod, gpiodevice, 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(gpiod, gpiodevice, 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(gpiod, gpiodevice, 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(gpiod, gpiodevice, 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(gpiod, gpiodevice, smbus): + from enviroplus import gas + + gas.cleanup() + + gas.setup() + gas.cleanup() diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..2b6d87b8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,27 @@ +[tox] +envlist = py,qa +skip_missing_interpreters = True +isolated_build = true +minversion = 4.0.0 + +[testenv] +commands = + coverage run -m pytest -v -r wsx + coverage report +deps = + mock + pytest>=3.1 + pytest-cov + build + +[testenv:qa] +commands = + check-manifest + python -m build --no-isolation + python -m twine check dist/* + isort --check . + ruff check . + codespell . +deps = + -r{toxinidir}/requirements-dev.txt + diff --git a/uninstall.sh b/uninstall.sh index e3174449..3314b7fc 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,33 +1,72 @@ #!/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}'` +FORCE=false +LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') +RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME +PYTHON="python" -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 +venv_check() { + PYTHON_BIN=$(which $PYTHON) + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + exit 1 + fi +} -cd library +user_check() { + if [ "$(id -u)" -eq 0 ]; then + printf "Script should not be run as root. Try './uninstall.sh'\n" + exit 1 + fi +} -printf "Unnstalling for Python 2..\n" -pip uninstall $LIBRARY_NAME +confirm() { + if $FORCE; then + true + else + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi + fi +} -if [ -f "/usr/bin/pip3" ]; then - printf "Uninstalling for Python 3..\n" - pip3 uninstall $LIBRARY_NAME -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)" +} -cd .. +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} -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 "%s Python Library: Uninstaller\n\n" "$LIBRARY_NAME" + +user_check +venv_check + +printf "Uninstalling for Python 3...\n" +$PYTHON -m pip uninstall "$LIBRARY_NAME" + +if [ -d "$RESOURCES_DIR" ]; then + if confirm "Would you like to delete $RESOURCES_DIR?"; then + rm -r "$RESOURCES_DIR" + fi +fi printf "Done!\n"