├── testing ├── weatherhat │ ├── history.py │ └── __init__.py └── st7789 │ └── __init__.py ├── .stickler.yml ├── examples ├── settings.yml ├── icons │ ├── icon-drop.png │ ├── icon-help.png │ ├── icon-alarm.png │ ├── icon-circle.png │ ├── icon-nodrop.png │ ├── icon-return.png │ ├── icon-snooze.png │ ├── icon-backdrop.png │ ├── icon-channel.png │ ├── icon-settings.png │ ├── icon-rightarrow.png │ └── icon-warningdrop.png ├── BME280.py ├── basic.py ├── BME280-compensated.py ├── lcd.py ├── buttons.py ├── averaging.py ├── adafruit-io.py └── weather.py ├── requirements-examples.txt ├── requirements-dev.txt ├── .gitignore ├── CHANGELOG.md ├── tox.ini ├── .github └── workflows │ ├── qa.yml │ ├── test.yml │ └── build.yml ├── LICENSE ├── tests ├── test_setup.py └── conftest.py ├── uninstall.sh ├── Makefile ├── check.sh ├── pyproject.toml ├── weatherhat ├── history.py └── __init__.py ├── README.md └── install.sh /testing/weatherhat/history.py: -------------------------------------------------------------------------------- 1 | ../../weatherhat/history.py -------------------------------------------------------------------------------- /.stickler.yml: -------------------------------------------------------------------------------- 1 | --- 2 | linters: 3 | flake8: 4 | python: 3 5 | max-line-length: 160 -------------------------------------------------------------------------------- /examples/settings.yml: -------------------------------------------------------------------------------- 1 | maximum_temperature: 40 2 | minimum_temperature: -20 3 | wind_trails: true 4 | -------------------------------------------------------------------------------- /requirements-examples.txt: -------------------------------------------------------------------------------- 1 | fonts 2 | font-manrope 3 | pyyaml 4 | adafruit-io 5 | numpy 6 | pillow 7 | -------------------------------------------------------------------------------- /examples/icons/icon-drop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-drop.png -------------------------------------------------------------------------------- /examples/icons/icon-help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-help.png -------------------------------------------------------------------------------- /examples/icons/icon-alarm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-alarm.png -------------------------------------------------------------------------------- /examples/icons/icon-circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-circle.png -------------------------------------------------------------------------------- /examples/icons/icon-nodrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-nodrop.png -------------------------------------------------------------------------------- /examples/icons/icon-return.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-return.png -------------------------------------------------------------------------------- /examples/icons/icon-snooze.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-snooze.png -------------------------------------------------------------------------------- /examples/icons/icon-backdrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-backdrop.png -------------------------------------------------------------------------------- /examples/icons/icon-channel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-channel.png -------------------------------------------------------------------------------- /examples/icons/icon-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-settings.png -------------------------------------------------------------------------------- /examples/icons/icon-rightarrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-rightarrow.png -------------------------------------------------------------------------------- /examples/icons/icon-warningdrop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimoroni/weatherhat-python/HEAD/examples/icons/icon-warningdrop.png -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | check-manifest 2 | ruff 3 | codespell 4 | isort 5 | twine 6 | hatch 7 | hatch-fancy-pypi-readme 8 | tox 9 | pdoc 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | _build/ 3 | *.o 4 | *.so 5 | *.a 6 | *.py[cod] 7 | *.egg-info 8 | dist/ 9 | __pycache__ 10 | .DS_Store 11 | *.deb 12 | *.dsc 13 | *.build 14 | *.changes 15 | *.orig.* 16 | packaging/*tar.xz 17 | library/debian/ 18 | .coverage 19 | .pytest_cache 20 | .tox 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | ----- 3 | 4 | * Repackage to hatch/pyproject 5 | * Port to gpiod (Pi 5 support) 6 | 7 | 0.0.2 8 | ----- 9 | 10 | * Values will now always be float 11 | * Fixed backlight pin 12 | * Fixed latest/average mph 13 | 14 | 0.0.1 15 | ----- 16 | 17 | * Initial Release 18 | -------------------------------------------------------------------------------- /examples/BME280.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import weatherhat 4 | 5 | print(""" 6 | BME280.py - Print raw readings from the BME280 weather sensor. 7 | Press Ctrl+C to exit! 8 | """) 9 | 10 | sensor = weatherhat.WeatherHAT() 11 | 12 | while True: 13 | sensor.update(interval=1.0) 14 | 15 | print(f""" 16 | Device temperature: {sensor.device_temperature:0.2f} *C 17 | Humidity: {sensor.humidity:0.2f} % 18 | Pressure: {sensor.pressure:0.2f} hPa 19 | """) 20 | 21 | time.sleep(1.0) 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py,qa 3 | skip_missing_interpreters = True 4 | isolated_build = true 5 | minversion = 4.0.0 6 | 7 | [testenv] 8 | commands = 9 | coverage run -m pytest -v -r wsx 10 | coverage report 11 | deps = 12 | mock 13 | pytest>=3.1 14 | pytest-cov 15 | build 16 | 17 | [testenv:qa] 18 | commands = 19 | check-manifest 20 | python -m build --no-isolation 21 | python -m twine check dist/* 22 | isort --check . 23 | ruff check . 24 | codespell . 25 | deps = 26 | check-manifest 27 | ruff 28 | codespell 29 | isort 30 | twine 31 | build 32 | hatch 33 | hatch-fancy-pypi-readme 34 | 35 | -------------------------------------------------------------------------------- /.github/workflows/qa.yml: -------------------------------------------------------------------------------- 1 | name: QA 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: linting & spelling 12 | runs-on: ubuntu-latest 13 | env: 14 | TERM: xterm-256color 15 | 16 | steps: 17 | - name: Checkout Code 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Python '3,11' 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.11' 24 | 25 | - name: Install Dependencies 26 | run: | 27 | make dev-deps 28 | 29 | - name: Run Quality Assurance 30 | run: | 31 | make qa 32 | 33 | - name: Run Code Checks 34 | run: | 35 | make check 36 | 37 | - name: Run Bash Code Checks 38 | run: | 39 | make shellcheck 40 | -------------------------------------------------------------------------------- /examples/basic.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import weatherhat 4 | 5 | sensor = weatherhat.WeatherHAT() 6 | 7 | print(""" 8 | basic.py - Basic example showing how to read Weather HAT's sensors. 9 | Press Ctrl+C to exit! 10 | """) 11 | 12 | 13 | while True: 14 | sensor.update(interval=60.0) 15 | 16 | wind_direction_cardinal = sensor.degrees_to_cardinal(sensor.wind_direction) 17 | 18 | print(f""" 19 | System temp: {sensor.device_temperature:0.2f} *C 20 | Temperature: {sensor.temperature:0.2f} *C 21 | 22 | Humidity: {sensor.humidity:0.2f} % 23 | Dew point: {sensor.dewpoint:0.2f} *C 24 | 25 | Light: {sensor.lux:0.2f} Lux 26 | 27 | Pressure: {sensor.pressure:0.2f} hPa 28 | 29 | Wind (avg): {sensor.wind_speed:0.2f} m/sec 30 | 31 | Rain: {sensor.rain:0.2f} mm/sec 32 | 33 | Wind (avg): {sensor.wind_direction:0.2f} degrees ({wind_direction_cardinal}) 34 | 35 | """) 36 | 37 | time.sleep(10.0) -------------------------------------------------------------------------------- /examples/BME280-compensated.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import weatherhat 4 | 5 | print(""" 6 | BME280-compensated.py - Print compensated readings from the BME280 weather sensor. 7 | Press Ctrl+C to exit! 8 | """) 9 | 10 | # We can compensate for the heat of the Pi and other environmental conditions using a simple offset. 11 | # Change this number to adjust temperature compensation! 12 | OFFSET = -7.5 13 | 14 | sensor = weatherhat.WeatherHAT() 15 | 16 | while True: 17 | sensor.temperature_offset = OFFSET 18 | sensor.update(interval=1.0) 19 | 20 | print(f""" 21 | Compensated air temperature: {sensor.temperature:0.2f} *C 22 | Raw temperature {sensor.device_temperature:0.2f} *C 23 | With offset {OFFSET} *C 24 | 25 | Relative humidity: {sensor.relative_humidity:0.2f} % 26 | Raw humidity {sensor.humidity:0.2f} % 27 | 28 | Pressure: {sensor.pressure:0.2f} hPa 29 | """) 30 | 31 | time.sleep(1.0) 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Python ${{ matrix.python }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ['3.9', '3.10', '3.11'] 16 | 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@v3 20 | 21 | - name: Set up Python ${{ matrix.python }} 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: ${{ matrix.python }} 25 | 26 | - name: Install Dependencies 27 | run: | 28 | make dev-deps 29 | 30 | - name: Run Tests 31 | run: | 32 | make pytest 33 | 34 | - name: Coverage 35 | if: ${{ matrix.python == '3.9' }} 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | run: | 39 | python -m pip install coveralls 40 | coveralls --service=github 41 | 42 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | name: Python ${{ matrix.python }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python: ['3.9', '3.10', '3.11'] 16 | 17 | env: 18 | RELEASE_FILE: ${{ github.event.repository.name }}-${{ github.event.release.tag_name || github.sha }}-py${{ matrix.python }} 19 | 20 | steps: 21 | - name: Checkout Code 22 | uses: actions/checkout@v4 23 | 24 | - name: Set up Python ${{ matrix.python }} 25 | uses: actions/setup-python@v5 26 | with: 27 | python-version: ${{ matrix.python }} 28 | 29 | - name: Install Dependencies 30 | run: | 31 | make dev-deps 32 | 33 | - name: Build Packages 34 | run: | 35 | make build 36 | 37 | - name: Upload Packages 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: ${{ env.RELEASE_FILE }} 41 | path: dist/ 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Pimoroni Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_setup.py: -------------------------------------------------------------------------------- 1 | def test_setup(gpiod, gpiodevice, ioe, bme280, ltr559, smbus2): 2 | import weatherhat 3 | _ = weatherhat.WeatherHAT() 4 | 5 | bus = smbus2.SMBus(1) 6 | 7 | bme280.BME280.assert_called_once_with(i2c_dev=bus) 8 | ltr559.LTR559.assert_called_once_with(i2c_dev=bus) 9 | ioe.IOE.assert_called_once_with(i2c_addr=0x12) 10 | 11 | 12 | def test_api(gpiod, gpiodevice, ioe, bme280, ltr559, smbus2): 13 | import weatherhat 14 | library = weatherhat.WeatherHAT() 15 | 16 | bus = smbus2.SMBus(1) 17 | 18 | bme280.BME280(i2c_dev=bus).get_temperature.return_value = 20.0 19 | bme280.BME280(i2c_dev=bus).get_pressure.return_value = 10600.0 20 | bme280.BME280(i2c_dev=bus).get_humidity.return_value = 60.0 21 | 22 | ltr559.LTR559(i2c_dev=bus).get_lux.return_value = 100.0 23 | 24 | ioe.IOE(i2c_addr=0x12, interrupt_pin=4).input.return_value = 2.3 25 | 26 | library.temperature_offset = 5.0 27 | 28 | library.update() 29 | 30 | assert library.wind_direction_raw == 2.3 31 | assert library.wind_direction == 180 32 | assert library.device_temperature == 20.0 33 | assert library.temperature == 25.0 34 | assert library.pressure == 10600.0 35 | assert library.relative_humidity == 15.0 36 | assert library.humidity == 60.0 37 | assert library.lux == 100.0 38 | -------------------------------------------------------------------------------- /examples/lcd.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from fonts.ttf import ManropeBold as UserFont 4 | from PIL import Image, ImageDraw, ImageFont 5 | from st7789 import ST7789 6 | 7 | print( 8 | """ 9 | lcd.py - Hello, World! example on the 1.54" LCD. 10 | Press Ctrl+C to exit! 11 | """ 12 | ) 13 | 14 | SPI_SPEED_MHZ = 80 15 | 16 | # Create LCD class instance. 17 | disp = ST7789( 18 | rotation=90, 19 | port=0, 20 | cs=1, 21 | dc=9, 22 | backlight=12, 23 | spi_speed_hz=SPI_SPEED_MHZ * 1000 * 1000, 24 | ) 25 | 26 | # Initialize display. 27 | disp.begin() 28 | 29 | # Width and height to calculate text position. 30 | WIDTH = disp.width 31 | HEIGHT = disp.height 32 | 33 | # New canvas to draw on. 34 | img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) 35 | draw = ImageDraw.Draw(img) 36 | 37 | # Text settings. 38 | font_size = 30 39 | font = ImageFont.truetype(UserFont, font_size) 40 | text_colour = (255, 255, 255) 41 | back_colour = (0, 170, 170) 42 | 43 | message = "Hello, World!" 44 | _, _, size_x, size_y = draw.textbbox((0, 0), message, font) 45 | 46 | # Calculate text position 47 | x = (WIDTH - size_x) / 2 48 | y = (HEIGHT / 2) - (size_y / 2) 49 | 50 | # Draw background rectangle and write text. 51 | draw.rectangle((0, 0, WIDTH, HEIGHT), back_colour) 52 | draw.text((x, y), message, font=font, fill=text_colour) 53 | disp.display(img) 54 | 55 | # Keep running. 56 | try: 57 | while True: 58 | pass 59 | 60 | # Turn off backlight on control-c 61 | except KeyboardInterrupt: 62 | disp.set_backlight(0) 63 | -------------------------------------------------------------------------------- /uninstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | FORCE=false 4 | LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') 5 | RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME 6 | PYTHON="python" 7 | 8 | 9 | venv_check() { 10 | PYTHON_BIN=$(which $PYTHON) 11 | if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then 12 | printf "This script should be run in a virtual Python environment.\n" 13 | exit 1 14 | fi 15 | } 16 | 17 | user_check() { 18 | if [ "$(id -u)" -eq 0 ]; then 19 | printf "Script should not be run as root. Try './uninstall.sh'\n" 20 | exit 1 21 | fi 22 | } 23 | 24 | confirm() { 25 | if $FORCE; then 26 | true 27 | else 28 | read -r -p "$1 [y/N] " response < /dev/tty 29 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 30 | true 31 | else 32 | false 33 | fi 34 | fi 35 | } 36 | 37 | prompt() { 38 | read -r -p "$1 [y/N] " response < /dev/tty 39 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 40 | true 41 | else 42 | false 43 | fi 44 | } 45 | 46 | success() { 47 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 48 | } 49 | 50 | inform() { 51 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 52 | } 53 | 54 | warning() { 55 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 56 | } 57 | 58 | printf "%s Python Library: Uninstaller\n\n" "$LIBRARY_NAME" 59 | 60 | user_check 61 | venv_check 62 | 63 | printf "Uninstalling for Python 3...\n" 64 | $PYTHON -m pip uninstall "$LIBRARY_NAME" 65 | 66 | if [ -d "$RESOURCES_DIR" ]; then 67 | if confirm "Would you like to delete $RESOURCES_DIR?"; then 68 | rm -r "$RESOURCES_DIR" 69 | fi 70 | fi 71 | 72 | printf "Done!\n" 73 | -------------------------------------------------------------------------------- /examples/buttons.py: -------------------------------------------------------------------------------- 1 | import select 2 | from datetime import timedelta 3 | 4 | import gpiod 5 | import gpiodevice 6 | from gpiod.line import Bias, Edge 7 | 8 | print("""buttons.py - Detect which button has been pressed 9 | This example should demonstrate how to: 10 | 1. set up gpiod to read buttons, 11 | 2. determine which button has been pressed 12 | Press Ctrl+C to exit! 13 | """) 14 | 15 | IP_PU_FE = gpiod.LineSettings(edge_detection=Edge.FALLING, bias=Bias.PULL_UP, debounce_period=timedelta(milliseconds=20)) 16 | 17 | # The buttons on Weather HAT are connected to pins 5, 6, 16 and 24 18 | # They short to ground, so we must Bias them with the PULL_UP resistor 19 | # and watch for a falling-edge. 20 | BUTTONS = {5: IP_PU_FE, 6: IP_PU_FE, 16: IP_PU_FE, 24: IP_PU_FE} 21 | 22 | # These correspond to buttons A, B, X and Y respectively 23 | LABELS = {5: 'A', 6: 'B', 16: 'X', 24: 'Y'} 24 | 25 | # Request the button pins from the gpiochip 26 | chip = gpiodevice.find_chip_by_platform() 27 | lines = chip.request_lines( 28 | consumer="buttons.py", 29 | config=BUTTONS 30 | ) 31 | 32 | # "handle_button" will be called every time a button is pressed 33 | # It receives one argument: the associated input pin. 34 | def handle_button(pin): 35 | label = LABELS[pin] 36 | print("Button press detected on pin: {} label: {}".format(pin, label)) 37 | 38 | # read_edge_events does not allow us to specify a timeout 39 | # so we'll use poll to check if any events are waiting for us... 40 | poll = select.poll() 41 | poll.register(lines.fd, select.POLLIN) 42 | 43 | # Poll for button events 44 | while True: 45 | if poll.poll(10): 46 | for event in lines.read_edge_events(): 47 | handle_button(event.line_offset) -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LIBRARY_NAME := $(shell hatch project metadata name 2> /dev/null) 2 | LIBRARY_VERSION := $(shell hatch version 2> /dev/null) 3 | 4 | .PHONY: usage install uninstall check pytest qa build-deps check tag wheel sdist clean dist testdeploy deploy 5 | usage: 6 | ifdef LIBRARY_NAME 7 | @echo "Library: ${LIBRARY_NAME}" 8 | @echo "Version: ${LIBRARY_VERSION}\n" 9 | else 10 | @echo "WARNING: You should 'make dev-deps'\n" 11 | endif 12 | @echo "Usage: make , where target is one of:\n" 13 | @echo "install: install the library locally from source" 14 | @echo "uninstall: uninstall the local library" 15 | @echo "dev-deps: install Python dev dependencies" 16 | @echo "check: perform basic integrity checks on the codebase" 17 | @echo "qa: run linting and package QA" 18 | @echo "pytest: run Python test fixtures" 19 | @echo "clean: clean Python build and dist directories" 20 | @echo "build: build Python distribution files" 21 | @echo "testdeploy: build and upload to test PyPi" 22 | @echo "deploy: build and upload to PyPi" 23 | @echo "tag: tag the repository with the current version\n" 24 | 25 | version: 26 | @hatch version 27 | 28 | install: 29 | ./install.sh --unstable 30 | 31 | uninstall: 32 | ./uninstall.sh 33 | 34 | dev-deps: 35 | python3 -m pip install -r requirements-dev.txt 36 | sudo apt install dos2unix shellcheck 37 | 38 | check: 39 | @bash check.sh 40 | 41 | shellcheck: 42 | shellcheck *.sh 43 | 44 | qa: 45 | tox -e qa 46 | 47 | pytest: 48 | tox -e py 49 | 50 | nopost: 51 | @bash check.sh --nopost 52 | 53 | tag: version 54 | git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" 55 | 56 | build: check 57 | @hatch build 58 | 59 | clean: 60 | -rm -r dist 61 | 62 | testdeploy: build 63 | twine upload --repository testpypi dist/* 64 | 65 | deploy: nopost build 66 | twine upload dist/* 67 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import mock 4 | import pytest 5 | 6 | 7 | @pytest.fixture(scope='function', autouse=True) 8 | def cleanup(): 9 | """This fixture removes modules under test from sys.modules. 10 | This ensures that each module is fully re-imported, along with 11 | the fixtures for each test function. 12 | """ 13 | 14 | yield None 15 | try: 16 | del sys.modules["weatherhat"] 17 | except KeyError: 18 | pass 19 | 20 | 21 | @pytest.fixture(scope='function', autouse=False) 22 | def smbus2(): 23 | """Mock smbus2 module.""" 24 | 25 | smbus2 = mock.MagicMock() 26 | smbus2.i2c_msg.read().__iter__.return_value = [0b00000000] 27 | sys.modules['smbus2'] = smbus2 28 | yield smbus2 29 | del sys.modules['smbus2'] 30 | 31 | 32 | @pytest.fixture(scope="function", autouse=False) 33 | def gpiod(): 34 | """Mock gpiod module.""" 35 | sys.modules["gpiod"] = mock.MagicMock() 36 | sys.modules["gpiod.line"] = mock.MagicMock() 37 | yield sys.modules["gpiod"] 38 | del sys.modules["gpiod"] 39 | 40 | 41 | @pytest.fixture(scope="function", autouse=False) 42 | def gpiodevice(): 43 | """Mock gpiodevice module.""" 44 | sys.modules["gpiodevice"] = mock.MagicMock() 45 | sys.modules["gpiodevice"].get_pin.return_value = (mock.Mock(), 0) 46 | yield sys.modules["gpiodevice"] 47 | del sys.modules["gpiodevice"] 48 | 49 | 50 | @pytest.fixture(scope='function', autouse=False) 51 | def bme280(): 52 | sys.modules['bme280'] = mock.MagicMock() 53 | return sys.modules['bme280'] 54 | del sys.modules["bme280"] 55 | 56 | 57 | @pytest.fixture(scope='function', autouse=False) 58 | def ltr559(): 59 | sys.modules['ltr559'] = mock.MagicMock() 60 | return sys.modules['ltr559'] 61 | del sys.modules["ltr559"] 62 | 63 | 64 | @pytest.fixture(scope='function') 65 | def ioe(): 66 | sys.modules['ioexpander'] = mock.MagicMock() 67 | return sys.modules['ioexpander'] 68 | del sys.modules["ioexpander"] -------------------------------------------------------------------------------- /examples/averaging.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import weatherhat 4 | from weatherhat import history 5 | 6 | print(""" 7 | averaging.py - Basic example showing how to use Weather HAT's history/averaging functions. 8 | Press Ctrl+C to exit! 9 | """) 10 | 11 | sensor = weatherhat.WeatherHAT() 12 | 13 | temperature = history.History() 14 | 15 | pressure = history.History() 16 | 17 | humidity = history.History() 18 | relative_humidity = history.History() 19 | dewpoint = history.History() 20 | 21 | lux = history.History() 22 | 23 | wind_speed = history.WindSpeedHistory() 24 | wind_direction = history.WindDirectionHistory() 25 | 26 | rain_mm_total = history.History() 27 | rain_mm_sec = history.History() 28 | 29 | 30 | while True: 31 | sensor.update(interval=5.0) 32 | 33 | # Append values to the histories 34 | temperature.append(sensor.temperature) 35 | pressure.append(sensor.pressure) 36 | humidity.append(sensor.humidity) 37 | relative_humidity.append(sensor.relative_humidity) 38 | dewpoint.append(sensor.dewpoint) 39 | lux.append(sensor.lux) 40 | wind_speed.append(sensor.wind_speed) 41 | 42 | if sensor.updated_wind_rain: 43 | wind_direction.append(sensor.wind_direction) 44 | rain_mm_total.append(sensor.rain_total) 45 | rain_mm_sec.append(sensor.rain) 46 | 47 | wind_direction_cardinal = wind_direction.average_compass(60) 48 | 49 | print(f""" 50 | System temp: Now: {sensor.device_temperature:0.2f} *C 51 | Temperature: Avg: {temperature.average():0.2f} *C - Now: {sensor.temperature:0.2f} *C 52 | 53 | Humidity: Avg: {humidity.average():0.2f} % - Now: {sensor.humidity:0.2f} % 54 | Dew point: Avg: {dewpoint.average():0.2f} *C - Now: {sensor.dewpoint:0.2f} *C 55 | 56 | Light: Avg: {lux.average():0.2f} Lux - Now: {sensor.lux:0.2f} Lux 57 | 58 | Pressure: Avg: {pressure.average():0.2f} hPa - Now: {sensor.pressure:0.2f} hPa 59 | 60 | Wind (avg): Avg: {wind_speed.average():0.2f} mph - Now: {sensor.wind_speed:0.2f} mph 61 | 62 | Rain: Avg: {rain_mm_sec.average():0.2f} mm/sec - Now: {sensor.rain:0.2f} mm/sec - Total: {rain_mm_total.total():0.2f} mm 63 | 64 | Wind (avg): Avg: {wind_direction.average(60):0.2f} degrees ({wind_direction_cardinal}) - Now: {sensor.wind_direction} degrees 65 | 66 | """) 67 | 68 | time.sleep(5.0) -------------------------------------------------------------------------------- /check.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script handles some basic QA checks on the source 4 | 5 | NOPOST=$1 6 | LIBRARY_NAME=$(hatch project metadata name) 7 | LIBRARY_VERSION=$(hatch version | awk -F "." '{print $1"."$2"."$3}') 8 | POST_VERSION=$(hatch version | awk -F "." '{print substr($4,0,length($4))}') 9 | TERM=${TERM:="xterm-256color"} 10 | 11 | success() { 12 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 13 | } 14 | 15 | inform() { 16 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 17 | } 18 | 19 | warning() { 20 | echo -e "$(tput setaf 1)$1$(tput sgr0)" 21 | } 22 | 23 | while [[ $# -gt 0 ]]; do 24 | K="$1" 25 | case $K in 26 | -p|--nopost) 27 | NOPOST=true 28 | shift 29 | ;; 30 | *) 31 | if [[ $1 == -* ]]; then 32 | printf "Unrecognised option: %s\n" "$1"; 33 | exit 1 34 | fi 35 | POSITIONAL_ARGS+=("$1") 36 | shift 37 | esac 38 | done 39 | 40 | inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" 41 | 42 | inform "Checking for trailing whitespace..." 43 | if grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO; then 44 | warning "Trailing whitespace found!" 45 | exit 1 46 | else 47 | success "No trailing whitespace found." 48 | fi 49 | printf "\n" 50 | 51 | inform "Checking for DOS line-endings..." 52 | if grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile; then 53 | warning "DOS line-endings found!" 54 | exit 1 55 | else 56 | success "No DOS line-endings found." 57 | fi 58 | printf "\n" 59 | 60 | inform "Checking CHANGELOG.md..." 61 | if ! grep "^${LIBRARY_VERSION}" CHANGELOG.md > /dev/null 2>&1; then 62 | warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." 63 | exit 1 64 | else 65 | success "Changes found for version ${LIBRARY_VERSION}." 66 | fi 67 | printf "\n" 68 | 69 | inform "Checking for git tag ${LIBRARY_VERSION}..." 70 | if ! git tag -l | grep -E "${LIBRARY_VERSION}$"; then 71 | warning "Missing git tag for version ${LIBRARY_VERSION}" 72 | fi 73 | printf "\n" 74 | 75 | if [[ $NOPOST ]]; then 76 | inform "Checking for .postN on library version..." 77 | if [[ "$POST_VERSION" != "" ]]; then 78 | warning "Found .$POST_VERSION on library version." 79 | inform "Please only use these for testpypi releases." 80 | exit 1 81 | else 82 | success "OK" 83 | fi 84 | fi 85 | -------------------------------------------------------------------------------- /testing/st7789/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import sys 3 | import tkinter 4 | 5 | from PIL import ImageTk 6 | 7 | modpath = pathlib.Path("../").resolve() 8 | sys.path.insert(0, str(modpath)) 9 | import RPi.GPIO as GPIO # noqa: E402 10 | 11 | 12 | class ST7789: 13 | def __init__( 14 | self, 15 | rotation=90, 16 | port=0, 17 | cs=1, 18 | dc=9, 19 | backlight=12, 20 | spi_speed_hz=0 21 | ): 22 | 23 | self._tk_done = False 24 | self.tk_root = tkinter.Tk() 25 | self.tk_root.title('Weather HAT') 26 | self.tk_root.geometry('240x240') 27 | self.tk_root.aspect(240, 240, 240, 240) 28 | self.tk_root.protocol('WM_DELETE_WINDOW', self._close_window) 29 | self.cv = None 30 | self.cvh = 240 31 | self.cvw = 240 32 | self.last_key = None 33 | 34 | def wait_for_window_close(self): 35 | while not self._tk_done: 36 | self.update() 37 | 38 | def resize(self, event): 39 | """Resize background image to window size.""" 40 | # adapted from: 41 | # https://stackoverflow.com/questions/24061099/tkinter-resize-background-image-to-window-size 42 | # https://stackoverflow.com/questions/19838972/how-to-update-an-image-on-a-canvas 43 | self.cvw = event.width 44 | self.cvh = event.height 45 | self.cv.config(width=self.cvw, height=self.cvh) 46 | image = self.disp_img_copy.resize([self.cvw, self.cvh]) 47 | self.photo = ImageTk.PhotoImage(image) 48 | self.cv.itemconfig(self.cvhandle, image=self.photo, anchor='nw') 49 | self.tk_root.update() 50 | 51 | def tk_update(self): 52 | self.tk_root.update_idletasks() 53 | self.tk_root.update() 54 | 55 | def _close_window(self): 56 | self._tk_done = True 57 | self.tk_root.destroy() 58 | 59 | def _key(self, event): 60 | buttons = [5, 6, 16, 24] 61 | labels = ["A", "B", "X", "Y"] 62 | key = event.keysym.upper() 63 | if key in labels: 64 | index = labels.index(key) 65 | pin = buttons[index] 66 | GPIO.handlers[pin][0](pin) 67 | 68 | def display(self, image): 69 | self.disp_img_copy = image.copy() 70 | self.photo = ImageTk.PhotoImage(self.disp_img_copy.resize((self.cvw, self.cvh))) 71 | 72 | if self.cv is None: 73 | self.cv = tkinter.Canvas(self.tk_root, width=240, height=240) 74 | self.cv.bind('', self.resize) 75 | self.cv.bind('', self._key) 76 | self.cv.focus_set() 77 | 78 | self.cv.pack(side='top', fill='both', expand='yes') 79 | self.cvhandle = self.cv.create_image(0, 0, image=self.photo, anchor='nw') 80 | 81 | self.tk_root.update() 82 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-fancy-pypi-readme"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "weatherhat" 7 | dynamic = ["version", "readme"] 8 | description = "Library for the Pimoroni Weather HAT" 9 | license = {file = "LICENSE"} 10 | requires-python = ">= 3.7" 11 | authors = [ 12 | { name = "Philip Howard", email = "phil@pimoroni.com" }, 13 | ] 14 | maintainers = [ 15 | { name = "Philip Howard", email = "phil@pimoroni.com" }, 16 | ] 17 | keywords = [ 18 | "Pi", 19 | "Raspberry", 20 | ] 21 | classifiers = [ 22 | "Development Status :: 4 - Beta", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Operating System :: POSIX :: Linux", 26 | "Programming Language :: Python :: 3", 27 | "Programming Language :: Python :: 3.7", 28 | "Programming Language :: Python :: 3.8", 29 | "Programming Language :: Python :: 3.9", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3 :: Only", 33 | "Topic :: Software Development", 34 | "Topic :: Software Development :: Libraries", 35 | "Topic :: System :: Hardware", 36 | ] 37 | dependencies = [ 38 | "pimoroni-bme280 >= 1.0.0", 39 | "ltr559 >= 1.0.0", 40 | "pimoroni-ioexpander >= 1.0.1", 41 | "st7789 >= 1.0.1", 42 | "smbus2" 43 | ] 44 | 45 | [project.urls] 46 | GitHub = "https://www.github.com/pimoroni/weatherhat-python" 47 | Homepage = "https://www.pimoroni.com" 48 | 49 | [tool.hatch.version] 50 | path = "weatherhat/__init__.py" 51 | 52 | [tool.hatch.build] 53 | include = [ 54 | "weatherhat/*.py", 55 | "README.md", 56 | "CHANGELOG.md", 57 | "LICENSE" 58 | ] 59 | 60 | [tool.hatch.build.targets.sdist] 61 | include = [ 62 | "*" 63 | ] 64 | exclude = [ 65 | ".*", 66 | "dist" 67 | ] 68 | 69 | [tool.hatch.metadata.hooks.fancy-pypi-readme] 70 | content-type = "text/markdown" 71 | fragments = [ 72 | { path = "README.md" }, 73 | { text = "\n" }, 74 | { path = "CHANGELOG.md" } 75 | ] 76 | 77 | [tool.ruff] 78 | exclude = [ 79 | '.tox', 80 | '.egg', 81 | '.git', 82 | '__pycache__', 83 | 'build', 84 | 'dist' 85 | ] 86 | line-length = 200 87 | 88 | [tool.codespell] 89 | skip = """ 90 | ./.tox,\ 91 | ./.egg,\ 92 | ./.git,\ 93 | ./__pycache__,\ 94 | ./build,\ 95 | ./dist.\ 96 | """ 97 | ignore-words-list = """ 98 | pres,\ 99 | """ 100 | 101 | [tool.isort] 102 | line_length = 200 103 | 104 | [tool.check-manifest] 105 | ignore = [ 106 | '.stickler.yml', 107 | 'boilerplate.md', 108 | 'check.sh', 109 | 'install.sh', 110 | 'uninstall.sh', 111 | 'Makefile', 112 | 'tox.ini', 113 | 'tests/*', 114 | 'examples/*', 115 | '.coveragerc', 116 | 'requirements-dev.txt' 117 | ] 118 | 119 | [tool.pimoroni] 120 | apt_packages = [] 121 | configtxt = [] 122 | commands = [ 123 | "printf \"Setting up i2c and SPI..\n\"", 124 | "sudo raspi-config nonint do_spi 0", 125 | "sudo raspi-config nonint do_i2c 0" 126 | ] 127 | -------------------------------------------------------------------------------- /examples/adafruit-io.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | from Adafruit_IO import Client, Dashboard, Feed, RequestError 4 | 5 | import weatherhat 6 | 7 | sensor = weatherhat.WeatherHAT() 8 | 9 | print(""" 10 | adafruit-io.py - Example showing how to send sensor data from Weather HAT into adafruit.io. 11 | Sign up for an account at https://io.adafruit.com/ to obtain a username and key. 12 | Press Ctrl+C to exit! 13 | """) 14 | 15 | # Set to your Adafruit IO username. 16 | # (go to https://accounts.adafruit.com to find your username) 17 | ADAFRUIT_IO_USERNAME = 'YOUR AIO USERNAME HERE' 18 | 19 | # Set to your Adafruit IO key. 20 | # Remember, your key is a secret, 21 | # so make sure not to publish it when you publish this code! 22 | ADAFRUIT_IO_KEY = 'YOUR AIO KEY HERE' 23 | 24 | # We can compensate for the heat of the Pi and other environmental conditions using a simple offset. 25 | # Change this number to adjust temperature compensation! 26 | OFFSET = -7.5 27 | 28 | # Create an instance of the REST client. 29 | aio = Client(ADAFRUIT_IO_USERNAME, ADAFRUIT_IO_KEY) 30 | 31 | # Create new feeds 32 | try: 33 | aio.create_feed(Feed(name="Temperature")) 34 | aio.create_feed(Feed(name="Relative Humidity")) 35 | aio.create_feed(Feed(name="Pressure")) 36 | aio.create_feed(Feed(name="Light")) 37 | aio.create_feed(Feed(name="Wind Speed")) 38 | aio.create_feed(Feed(name="Wind Direction")) 39 | aio.create_feed(Feed(name="Rain")) 40 | print("Feeds created!") 41 | except RequestError: 42 | print("Feeds not created - perhaps they already exist?") 43 | 44 | temperature_feed = aio.feeds('temperature') 45 | humidity_feed = aio.feeds('relative-humidity') 46 | pressure_feed = aio.feeds('pressure') 47 | light_feed = aio.feeds('light') 48 | windspeed_feed = aio.feeds('wind-speed') 49 | winddirection_feed = aio.feeds('wind-direction') 50 | rain_feed = aio.feeds('rain') 51 | 52 | # Create new dashboard 53 | try: 54 | dashboard = aio.create_dashboard(Dashboard(name="Weather Dashboard")) 55 | print("Dashboard created!") 56 | except RequestError: 57 | print("Dashboard not created - perhaps it already exists?") 58 | 59 | dashboard = aio.dashboards('weather-dashboard') 60 | 61 | print("Find your dashboard at: " + 62 | "https://io.adafruit.com/{0}/dashboards/{1}".format(ADAFRUIT_IO_USERNAME, 63 | dashboard.key)) 64 | 65 | # Read the BME280 and discard the initial nonsense readings 66 | sensor.update(interval=10.0) 67 | sensor.temperature_offset = OFFSET 68 | temperature = sensor.temperature 69 | humidity = sensor.relative_humidity 70 | pressure = sensor.pressure 71 | print("Discarding the first few BME280 readings...") 72 | sleep(10.0) 73 | 74 | # Read all the sensors and start sending data 75 | 76 | while True: 77 | sensor.update(interval=30.0) 78 | 79 | wind_direction_cardinal = sensor.degrees_to_cardinal(sensor.wind_direction) 80 | 81 | temperature = sensor.temperature 82 | humidity = sensor.relative_humidity 83 | pressure = sensor.pressure 84 | light = sensor.lux 85 | windspeed = sensor.wind_speed 86 | winddirection = wind_direction_cardinal 87 | rain = sensor.rain 88 | 89 | try: 90 | aio.send_data(temperature_feed.key, temperature) 91 | aio.send_data(humidity_feed.key, humidity) 92 | aio.send_data(pressure_feed.key, pressure) 93 | aio.send_data(light_feed.key, light) 94 | aio.send_data(windspeed_feed.key, windspeed) 95 | aio.send_data(winddirection_feed.key, winddirection) 96 | aio.send_data(rain_feed.key, rain) 97 | print('Data sent to adafruit.io') 98 | except Exception as e: 99 | print(e) 100 | 101 | # leave at least 30 seconds between updates for free Adafruit.io accounts 102 | sleep(30.0) 103 | -------------------------------------------------------------------------------- /testing/weatherhat/__init__.py: -------------------------------------------------------------------------------- 1 | import math 2 | import random 3 | import threading 4 | import time 5 | 6 | from .history import wind_degrees_to_cardinal 7 | 8 | __version__ = '0.0.1' 9 | 10 | 11 | # Wind Vane 12 | PIN_WV = 8 # P0.3 ANE6 13 | 14 | # Anemometer 15 | PIN_ANE1 = 5 # P0.0 16 | PIN_ANE2 = 6 # P0.1 17 | 18 | ANE_RADIUS = 7 # Radius from center to the center of a cup, in CM 19 | ANE_CIRCUMFERENCE = ANE_RADIUS * 2 * math.pi 20 | ANE_FACTOR = 2.18 # Anemometer factor 21 | 22 | # Rain gauge 23 | PIN_R2 = 3 # P1.2 24 | PIN_R3 = 7 # P1.1 25 | PIN_R4 = 2 # P1.0 26 | PIN_R5 = 1 # P1.5 27 | RAIN_MM_PER_TICK = 0.2794 28 | 29 | wind_direction_to_degrees = { 30 | 0.9: 0, 31 | 2.0: 45, 32 | 3.0: 90, 33 | 2.8: 135, 34 | 2.5: 180, 35 | 1.5: 225, 36 | 0.3: 270, 37 | 0.6: 315 38 | } 39 | 40 | 41 | class WeatherHAT: 42 | def __init__(self): 43 | self._lock = threading.Lock() 44 | 45 | # Data API... kinda 46 | self.temperature_offset = -7.5 47 | self.device_temperature = 0 48 | self.temperature = 0 49 | 50 | self.pressure = 0 51 | 52 | self.humidity = 0 53 | self.relative_humidity = 0 54 | self.dewpoint = 0 55 | 56 | self.lux = 0 57 | 58 | self.wind_speed = 0 59 | self.wind_direction = 0 60 | 61 | self.rain = 0 62 | self.rain_total = 0 63 | 64 | self._rain_counts = 0 65 | self._wind_counts = 0 66 | 67 | self.updated_wind_rain = False 68 | 69 | self.reset_counts() 70 | 71 | def reset_counts(self): 72 | self._t_start = time.time() 73 | 74 | def compensate_humidity(self, humidity, temperature, corrected_temperature): 75 | """Compensate humidity. 76 | 77 | Convert humidity to relative humidity. 78 | 79 | """ 80 | dewpoint = self.get_dewpoint(humidity, temperature) 81 | corrected_humidity = 100 - (5 * (corrected_temperature - dewpoint)) - 20 82 | return min(100, max(0, corrected_humidity)) 83 | 84 | def get_dewpoint(self, humidity, temperature): 85 | """Calculate Dewpoint.""" 86 | return temperature - ((100 - humidity) / 5) 87 | 88 | def hpa_to_inches(self, hpa): 89 | """Convert hextopascals to inches of mercury.""" 90 | return hpa * 0.02953 91 | 92 | def degrees_to_cardinal(self, degrees): 93 | value, cardinal = min(wind_degrees_to_cardinal.items(), key=lambda item: abs(item[0] - degrees)) 94 | return cardinal 95 | 96 | def update(self, interval=60.0): 97 | # Time elapsed since last update 98 | delta = time.time() - self._t_start 99 | self.updated_wind_rain = False 100 | 101 | # Always update TPHL & Wind Direction 102 | self._lock.acquire(blocking=True) 103 | 104 | # TODO make history depth configurable 105 | # TODO make update interval for sensors fixed so history always represents a known period 106 | 107 | self.device_temperature = 10.0 + math.sin(time.time() / 10.0) * 20.0 108 | self.temperature = self.device_temperature + self.temperature_offset 109 | 110 | self.pressure = 1050.0 + math.sin(time.time() / 10.0) * 50.0 111 | self.humidity = 50 + math.sin(time.time() / 10.0) * 25.0 112 | 113 | self.relative_humidity = self.compensate_humidity(self.humidity, self.device_temperature, self.temperature) 114 | 115 | self.dewpoint = self.get_dewpoint(self.humidity, self.device_temperature) 116 | 117 | self.lux = 500.0 + math.sin(time.time()) * 250.0 118 | 119 | self.wind_direction_raw = random.randint(0, 33) / 10.0 120 | 121 | self._lock.release() 122 | 123 | value, self.wind_direction = min(wind_direction_to_degrees.items(), key=lambda item: abs(item[0] - self.wind_direction_raw)) 124 | 125 | # Don't update rain/wind da`ta until we've sampled for long enough 126 | if delta < interval: 127 | return 128 | 129 | self.updated_wind_rain = True 130 | 131 | wind_counts = 2 + math.sin(time.time()) * 1 132 | rain_counts = 5 + math.sin(time.time()) * 5 133 | 134 | rain_hz = rain_counts / delta 135 | wind_hz = wind_counts / delta 136 | self.rain_total = rain_counts * RAIN_MM_PER_TICK 137 | self.reset_counts() 138 | 139 | # print(delta, rain_hz, wind_hz) 140 | 141 | # wind speed of 2.4km/h causes the switch to close once per second 142 | 143 | wind_hz /= 2.0 # Two pulses per rotation 144 | wind_cms = wind_hz * ANE_CIRCUMFERENCE * ANE_FACTOR 145 | self.wind_speed = wind_cms / 100.0 146 | 147 | self.rain = rain_hz * RAIN_MM_PER_TICK 148 | -------------------------------------------------------------------------------- /weatherhat/history.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | wind_degrees_to_cardinal = { 4 | 0: "North", 5 | 45: "North East", 6 | 90: "East", 7 | 135: "South East", 8 | 180: "South", 9 | 225: "South West", 10 | 270: "West", 11 | 315: "North West" 12 | } 13 | 14 | wind_degrees_to_short_cardinal = { 15 | 0: "N", 16 | 45: "NE", 17 | 90: "E", 18 | 135: "SE", 19 | 180: "S", 20 | 225: "SW", 21 | 270: "W", 22 | 315: "NW" 23 | } 24 | 25 | 26 | class HistoryEntry: 27 | __slots__ = 'value', 'timestamp' 28 | 29 | def __init__(self, value, timestamp=None): 30 | self.timestamp = timestamp if timestamp is not None else time.time() 31 | self.value = value 32 | 33 | 34 | class History: 35 | def __init__(self, history_depth=1200): 36 | self._history = [] 37 | self.history_depth = history_depth 38 | 39 | def append(self, value, timestamp=None): 40 | self._history.append(HistoryEntry(value, timestamp=timestamp)) 41 | self._history = self._history[-self.history_depth:] # Prune the buffer 42 | 43 | def average(self, sample_over=None): 44 | history = self.history(sample_over) 45 | num_samples = len(history) 46 | if num_samples == 0: 47 | return 0 48 | return sum([entry.value for entry in history]) / float(num_samples) 49 | 50 | def timespan(self): 51 | return self._history[0].timestamp, self._history[-1].timestamp 52 | 53 | def min(self, sample_over=None): 54 | return min(self.history(sample_over)) 55 | 56 | def max(self, sample_over=None): 57 | return max(self.history(sample_over)) 58 | 59 | def median(self, sample_over=None): 60 | history = self.history(sample_over) 61 | median = int(len(history) / 2) 62 | return history[median].value 63 | 64 | def total(self, sample_over=None): 65 | history = self.history(sample_over) 66 | return sum([entry.value for entry in history]) 67 | 68 | def latest(self): 69 | return self._history[-1] 70 | 71 | def history(self, depth=None): 72 | if depth is None: 73 | return self._history 74 | depth = min(depth, len(self._history)) 75 | return self._history[-depth:] 76 | 77 | 78 | class WindSpeedHistory(History): 79 | def ms_to_kmph(self, ms): 80 | """Convert meters/second to kilometers/hour.""" 81 | return (ms * 60 * 60) / 1000.0 82 | 83 | def latest_kmph(self): 84 | return self.ms_to_kmph(self.latest().value) 85 | 86 | def average_kmph(self, sample_over=None): 87 | return self.ms_to_kmph(self.average(sample_over)) 88 | 89 | def gust_kmph(self, seconds=3.0): 90 | """Wind gust in kilometers/hour.""" 91 | return self.ms_to_kmph(self.gust(seconds)) 92 | 93 | def ms_to_mph(self, ms): 94 | """Convert meters/second to miles/hour.""" 95 | return ((ms * 60 * 60) / 1000.0) * 0.621371 96 | 97 | def latest_mph(self): 98 | return self.ms_to_mph(self.latest().value) 99 | 100 | def average_mph(self, sample_over=None): 101 | return self.ms_to_mph(self.average(sample_over)) 102 | 103 | def gust_mph(self, seconds=3.0): 104 | """Wind gust in miles/hour.""" 105 | return self.ms_to_mph(self.gust(seconds)) 106 | 107 | def gust(self, seconds=3.0): 108 | """Wind gust in meters/second.""" 109 | cut_off_time = time.time() - seconds 110 | samples = [entry.value for entry in self.history() if entry.timestamp >= cut_off_time] 111 | return max(samples) 112 | 113 | 114 | class WindDirectionHistory(History): 115 | def degrees_to_cardinal(self, degrees): 116 | value, cardinal = min(wind_degrees_to_cardinal.items(), key=lambda item: abs(item[0] - degrees)) 117 | return cardinal 118 | 119 | def degrees_to_short_cardinal(self, degrees): 120 | value, cardinal = min(wind_degrees_to_short_cardinal.items(), key=lambda item: abs(item[0] - degrees)) 121 | return cardinal 122 | 123 | def average_compass(self, sample_over=None): 124 | return self.degrees_to_cardinal(self.average(sample_over)) 125 | 126 | def average_short_compass(self, sample_over=None): 127 | return self.degrees_to_short_cardinal(self.average(sample_over)) 128 | 129 | def latest_compass(self): 130 | return self.degrees_to_cardinal(self.latest().value) 131 | 132 | def latest_short_compass(self): 133 | return self.degrees_to_short_cardinal(self.latest().value) 134 | 135 | def history_compass(self, depth=None): 136 | return [HistoryEntry(self.degrees_to_cardinal(entry.value), timestamp=entry.timestamp) for entry in self.history(depth)] 137 | 138 | def history_short_compass(self, depth=None): 139 | return [HistoryEntry(self.degrees_to_short_cardinal(entry.value), timestamp=entry.timestamp) for entry in self.history(depth)] 140 | -------------------------------------------------------------------------------- /weatherhat/__init__.py: -------------------------------------------------------------------------------- 1 | import math 2 | import select 3 | import threading 4 | import time 5 | 6 | import gpiod 7 | import gpiodevice 8 | import ioexpander as io 9 | from bme280 import BME280 10 | from gpiod.line import Bias, Edge 11 | from ltr559 import LTR559 12 | from smbus2 import SMBus 13 | 14 | from .history import wind_degrees_to_cardinal 15 | 16 | __version__ = '1.0.0' 17 | 18 | # Wind Vane 19 | PIN_WV = 8 # P0.3 ANE6 20 | 21 | # Anemometer 22 | PIN_ANE1 = 5 # P0.0 23 | PIN_ANE2 = 6 # P0.1 24 | 25 | ANE_RADIUS = 7 # Radius from center to the center of a cup, in CM 26 | ANE_CIRCUMFERENCE = ANE_RADIUS * 2 * math.pi 27 | ANE_FACTOR = 2.18 # Anemometer factor 28 | 29 | # Rain gauge 30 | PIN_R2 = 3 # P1.2 31 | PIN_R3 = 7 # P1.1 32 | PIN_R4 = 2 # P1.0 33 | PIN_R5 = 1 # P1.5 34 | RAIN_MM_PER_TICK = 0.2794 35 | 36 | wind_direction_to_degrees = { 37 | 0.9: 0, 38 | 2.0: 45, 39 | 3.0: 90, 40 | 2.8: 135, 41 | 2.5: 180, 42 | 1.5: 225, 43 | 0.3: 270, 44 | 0.6: 315 45 | } 46 | 47 | 48 | class WeatherHAT: 49 | def __init__(self): 50 | self.updated_wind_rain = False 51 | self._interrupt_pin = 4 52 | self._lock = threading.Lock() 53 | self._i2c_dev = SMBus(1) 54 | 55 | self._bme280 = BME280(i2c_dev=self._i2c_dev) 56 | self._ltr559 = LTR559(i2c_dev=self._i2c_dev) 57 | 58 | self._ioe = io.IOE(i2c_addr=0x12) 59 | 60 | self._chip = gpiodevice.find_chip_by_platform() 61 | 62 | self._int = self._chip.request_lines( 63 | consumer="weatherhat", 64 | config={ 65 | self._interrupt_pin: gpiod.LineSettings( 66 | edge_detection=Edge.FALLING, bias=Bias.PULL_UP 67 | ) 68 | } 69 | ) 70 | 71 | # Input voltage of IO Expander, this is 3.3 on Breakout Garden 72 | self._ioe.set_adc_vref(3.3) 73 | 74 | # Wind Vane 75 | self._ioe.set_mode(PIN_WV, io.ADC) 76 | 77 | # Anemometer 78 | self._ioe.set_mode(PIN_ANE1, io.OUT) 79 | self._ioe.output(PIN_ANE1, 0) 80 | self._ioe.set_pin_interrupt(PIN_ANE2, True) 81 | self._ioe.setup_switch_counter(PIN_ANE2) 82 | 83 | # Rain Sensor 84 | self._ioe.set_mode(PIN_R2, io.IN_PU) 85 | self._ioe.set_mode(PIN_R3, io.OUT) 86 | self._ioe.set_mode(PIN_R4, io.IN_PU) 87 | self._ioe.setup_switch_counter(PIN_R4) 88 | self._ioe.set_mode(PIN_R5, io.IN_PU) 89 | self._ioe.output(PIN_R3, 0) 90 | self._ioe.set_pin_interrupt(PIN_R4, True) 91 | 92 | # Data API... kinda 93 | self.temperature_offset = -7.5 94 | self.device_temperature = 0.0 95 | self.temperature = 0.0 96 | 97 | self.pressure = 0.0 98 | 99 | self.humidity = 0.0 100 | self.relative_humidity = 0.0 101 | self.dewpoint = 0.0 102 | 103 | self.lux = 0.0 104 | 105 | self.wind_speed = 0.0 106 | self.wind_direction = 0.0 107 | 108 | self.rain = 0.0 109 | self.rain_total = 0.0 110 | 111 | self.reset_counts() 112 | 113 | self._poll_thread = threading.Thread(target=self._t_poll_ioexpander) 114 | self._poll_thread.start() 115 | 116 | self._ioe.enable_interrupt_out() 117 | self._ioe.clear_interrupt() 118 | 119 | def __del__(self): 120 | self._polling = False 121 | self._poll_thread.join() 122 | 123 | def reset_counts(self): 124 | self._lock.acquire(blocking=True) 125 | self._ioe.clear_switch_counter(PIN_ANE2) 126 | self._ioe.clear_switch_counter(PIN_R4) 127 | self._lock.release() 128 | 129 | self._wind_counts = 0 130 | self._rain_counts = 0 131 | self._last_wind_counts = 0 132 | self._last_rain_counts = 0 133 | self._t_start = time.time() 134 | 135 | def compensate_humidity(self, humidity, temperature, corrected_temperature): 136 | """Compensate humidity. 137 | 138 | Convert humidity to relative humidity. 139 | 140 | """ 141 | dewpoint = self.get_dewpoint(humidity, temperature) 142 | corrected_humidity = 100 - (5 * (corrected_temperature - dewpoint)) - 20 143 | return min(100, max(0, corrected_humidity)) 144 | 145 | def get_dewpoint(self, humidity, temperature): 146 | """Calculate Dewpoint.""" 147 | return temperature - ((100 - humidity) / 5) 148 | 149 | def hpa_to_inches(self, hpa): 150 | """Convert hectopascals to inches of mercury.""" 151 | return hpa * 0.02953 152 | 153 | def degrees_to_cardinal(self, degrees): 154 | value, cardinal = min(wind_degrees_to_cardinal.items(), key=lambda item: abs(item[0] - degrees)) 155 | return cardinal 156 | 157 | def _t_poll_ioexpander(self): 158 | self._polling = True 159 | poll = select.poll() 160 | poll.register(self._int.fd, select.POLLIN) 161 | while self._polling: 162 | if not poll.poll(10): 163 | continue 164 | for event in self._int.read_edge_events(): 165 | if event.line_offset == self._interrupt_pin: 166 | self.handle_ioe_interrupt() 167 | time.sleep(1.0 / 100) 168 | 169 | def update(self, interval=60.0): 170 | 171 | # Time elapsed since last update 172 | delta = float(time.time() - self._t_start) 173 | 174 | self.updated_wind_rain = False 175 | 176 | # Always update TPHL & Wind Direction 177 | self._lock.acquire(blocking=True) 178 | 179 | self.device_temperature = self._bme280.get_temperature() 180 | self.temperature = self.device_temperature + self.temperature_offset 181 | 182 | self.pressure = self._bme280.get_pressure() 183 | self.humidity = self._bme280.get_humidity() 184 | 185 | self.relative_humidity = self.compensate_humidity(self.humidity, self.device_temperature, self.temperature) 186 | 187 | self.dewpoint = self.get_dewpoint(self.humidity, self.device_temperature) 188 | 189 | self.lux = self._ltr559.get_lux() 190 | 191 | self.wind_direction_raw = self._ioe.input(PIN_WV) 192 | 193 | self._lock.release() 194 | 195 | value, self.wind_direction = min(wind_direction_to_degrees.items(), key=lambda item: abs(item[0] - self.wind_direction_raw)) 196 | 197 | # Don't update rain/wind data until we've sampled for long enough 198 | if delta < interval: 199 | return 200 | 201 | self.updated_wind_rain = True 202 | 203 | rain_hz = self._rain_counts / delta 204 | wind_hz = self._wind_counts / delta 205 | self.rain_total = self._rain_counts * RAIN_MM_PER_TICK 206 | self.reset_counts() 207 | 208 | # wind speed of 2.4km/h causes the switch to close once per second 209 | 210 | wind_hz /= 2.0 # Two pulses per rotation 211 | wind_cms = wind_hz * ANE_CIRCUMFERENCE * ANE_FACTOR 212 | self.wind_speed = wind_cms / 100.0 213 | 214 | self.rain = rain_hz * RAIN_MM_PER_TICK 215 | 216 | def handle_ioe_interrupt(self): 217 | self._lock.acquire(blocking=True) 218 | self._ioe.clear_interrupt() 219 | 220 | wind_counts, _ = self._ioe.read_switch_counter(PIN_ANE2) 221 | rain_counts, _ = self._ioe.read_switch_counter(PIN_R4) 222 | 223 | # If the counter value is *less* than the previous value 224 | # then we know the 7-bit switch counter overflowed 225 | # We bump the count value by the lost counts between last_wind and 128 226 | # since at 127 counts, one more count will overflow us back to 0 227 | if wind_counts < self._last_wind_counts: 228 | self._wind_counts += 128 - self._last_wind_counts 229 | self._wind_counts += wind_counts 230 | else: 231 | self._wind_counts += wind_counts - self._last_wind_counts 232 | 233 | self._last_wind_counts = wind_counts 234 | 235 | if rain_counts < self._last_rain_counts: 236 | self._rain_counts += 128 - self._last_rain_counts 237 | self._rain_counts += rain_counts 238 | else: 239 | self._rain_counts += rain_counts - self._last_rain_counts 240 | 241 | self._last_rain_counts = rain_counts 242 | 243 | # print(wind_counts, rain_counts, self._wind_counts, self._rain_counts) 244 | 245 | self._lock.release() 246 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Weather HAT Python Library & Examples 2 | 3 | [![Build Status](https://img.shields.io/github/actions/workflow/status/pimoroni/weatherhat-python/test.yml?branch=main)](https://github.com/pimoroni/weatherhat-python/actions/workflows/test.yml) 4 | [![Coverage Status](https://coveralls.io/repos/github/pimoroni/weatherhat-python/badge.svg?branch=master)](https://coveralls.io/github/pimoroni/weatherhat-python?branch=master) 5 | [![PyPi Package](https://img.shields.io/pypi/v/weatherhat.svg)](https://pypi.python.org/pypi/weatherhat) 6 | [![Python Versions](https://img.shields.io/pypi/pyversions/weatherhat.svg)](https://pypi.python.org/pypi/weatherhat) 7 | 8 | Weather HAT is a tidy all-in-one solution for hooking up climate and environmental sensors to a Raspberry Pi. It has a bright 1.54" LCD screen and four buttons for inputs. The onboard sensors can measure temperature, humidity, pressure and light. The RJ11 connectors will let you easily attach wind and rain sensors. It will work with any Raspberry Pi with a 40 pin header. 9 | 10 | ## Where to buy 11 | 12 | * [Weather HAT](https://shop.pimoroni.com/products/weather-hat-only) 13 | * [Weather HAT + Weather Sensors Kit](https://shop.pimoroni.com/products/weather-hat) 14 | 15 | # Installing 16 | 17 | We'd recommend using this library with Raspberry Pi OS Bookworm or later. It requires Python ≥3.7. 18 | 19 | ## Full install (recommended): 20 | 21 | We've created an easy installation script that will install all pre-requisites and get your Weather HAT 22 | up and running with minimal efforts. To run it, fire up Terminal which you'll find in Menu -> Accessories -> Terminal 23 | on your Raspberry Pi desktop, as illustrated below: 24 | 25 | ![Finding the terminal](http://get.pimoroni.com/resources/github-repo-terminal.png) 26 | 27 | In the new terminal window type the commands exactly as it appears below (check for typos) and follow the on-screen instructions: 28 | 29 | ```bash 30 | git clone https://github.com/pimoroni/weatherhat-python 31 | cd weatherhat-python 32 | ./install.sh 33 | ``` 34 | 35 | **Note** Libraries will be installed in the "pimoroni" virtual environment, you will need to activate it to run examples: 36 | 37 | ``` 38 | source ~/.virtualenvs/pimoroni/bin/activate 39 | ``` 40 | 41 | ## Development: 42 | 43 | If you want to contribute, or like living on the edge of your seat by having the latest code, you can install the development version like so: 44 | 45 | ```bash 46 | git clone https://github.com/pimoroni/weatherhat-python 47 | cd weatherhat-python 48 | ./install.sh --unstable 49 | ``` 50 | 51 | ## Install stable library from PyPi and configure manually 52 | 53 | * Set up a virtual environment: `python3 -m venv --system-site-packages $HOME/.virtualenvs/pimoroni` 54 | * Switch to the virtual environment: `source ~/.virtualenvs/pimoroni/bin/activate` 55 | * Install the library: `pip install weatherhat` 56 | 57 | In some cases you may need to us `sudo` or install pip with: `sudo apt install python3-pip`. 58 | 59 | This will not make any configuration changes, so you may also need to enable: 60 | 61 | * i2c: `sudo raspi-config nonint do_i2c 0` 62 | * spi: `sudo raspi-config nonint do_spi 0` 63 | 64 | You can optionally run `sudo raspi-config` or the graphical Raspberry Pi Configuration UI to enable interfaces. 65 | 66 | Some of the examples have additional dependencies. You can install them with: 67 | 68 | ```bash 69 | pip install fonts font-manrope pyyaml adafruit-io numpy pillow 70 | ``` 71 | 72 | You may also need to install `libatlas-base-dev`: 73 | 74 | ``` 75 | sudo apt install libatlas-base-dev 76 | ``` 77 | 78 | # Using The Library 79 | 80 | Import the `weatherhat` module and create an instance of the `WeatherHAT` class. 81 | 82 | ```python 83 | import weatherhat 84 | 85 | sensor = weatherhat.WeatherHAT() 86 | ``` 87 | 88 | Weather HAT updates the sensors when you call `update(interval=5)`. 89 | 90 | Temperature, pressure, humidity, light and wind direction are updated continuously. 91 | 92 | Rain and Wind measurements are measured over an `interval` period. Weather HAT will count ticks of the rain gauge and (half) rotations of the anemometer, calculate rain/wind every `interval` seconds and reset the counts for the next pass. 93 | 94 | For example the following code will update rain/wind speed every 5 seconds, and all other readings will be updated on demand: 95 | 96 | ```python 97 | import time 98 | import weatherhat 99 | 100 | sensor = weatherhat.WeatherHAT() 101 | 102 | while True: 103 | sensor.update(interval=5.0) 104 | time.sleep(1.0) 105 | ``` 106 | 107 | # Averaging Readings 108 | 109 | The Weather HAT library supplies set of "history" classes intended to save readings over a period of time and provide access to things like minimum, maximum and average values with unit conversions. 110 | 111 | For example `WindSpeedHistory` allows you to store wind readings and retrieve them in mp/h or km/h, in addition to determining the "gust" (maximum wind speed) in a given period of time: 112 | 113 | ```python 114 | import time 115 | import weatherhat 116 | from weatherhat.history import WindSpeedHistory 117 | 118 | sensor = weatherhat.WeatherHAT() 119 | wind_speed_history = WindSpeedHistory() 120 | 121 | while True: 122 | sensor.update(interval=5.0) 123 | if sensor.updated_wind_rain: 124 | wind_speed_history.append(sensor.wind_speed) 125 | print(f"Average wind speed: {wind_speed_history.average_mph()}mph") 126 | print(f"Wind gust: {wind_speed_history.gust_mph()}mph") 127 | time.sleep(1.0) 128 | ``` 129 | 130 | # Quick Reference 131 | 132 | ## Temperature 133 | 134 | Temperature readings are given as degrees celsius and are measured from the Weather HAT's onboard BME280. 135 | 136 | ### Device Temperature 137 | 138 | ```python 139 | sensor.device_temperature 140 | ``` 141 | 142 | Device temperature in degrees celsius. 143 | 144 | This is the temperature read directly from the BME280 onboard Weather HAT. It's not compensated and tends to read slightly higher than ambient due to heat from the Pi. 145 | 146 | ### Compensated (Air) Temperature 147 | 148 | ```python 149 | sensor.temperature 150 | ``` 151 | 152 | Temperature in degrees celsius. 153 | 154 | This is the temperature once an offset has been applied. This offset is fixed, and taken from `sensor.temperature_offset`. 155 | 156 | ## Pressure 157 | 158 | ```python 159 | sensor.pressure 160 | ``` 161 | 162 | Pressure in hectopascals. 163 | 164 | ## Humidity 165 | 166 | ```python 167 | sensor.humidity 168 | ``` 169 | 170 | Humidity in %. 171 | 172 | ### Relative Humidity 173 | 174 | ```python 175 | sensor.relative_humidity 176 | ``` 177 | 178 | Relative humidity in %. 179 | 180 | Relative humidity is the water content of the air compensated for temperature, since warmer air can hold more water. 181 | 182 | It's expressed as a percentage from 0 (no moisture) to 100 (fully saturated). 183 | 184 | ### Dew Point 185 | 186 | ```python 187 | sensor.dewpoint 188 | ``` 189 | 190 | Dew point in degrees celsius. 191 | 192 | Dew point is the temperature at which water - at the current humidity - will condense out of the air. 193 | 194 | ## Light / Lux 195 | 196 | ```python 197 | sensor.lux 198 | ``` 199 | 200 | Light is given in lux. 201 | 202 | Lux ranges from 0 (complete darkness) to 64,000 (full brightness). 203 | 204 | ## Wind 205 | 206 | Both wind and rain are updated on an interval, rather than on-demand. 207 | 208 | To see if an `update()` call has resulted in new wind/rain measurements, check: 209 | 210 | ```python 211 | sensor.updated_wind_rain 212 | ``` 213 | 214 | ### Wind Direction 215 | 216 | ```python 217 | sensor.wind_direction 218 | ``` 219 | 220 | Wind direction in degrees. 221 | 222 | Wind direction is measured using a potentiometer and uses an analog reading internally. This is converted to degrees for convenience, and will snap to the nearest 45-degree increment with 0 degrees indicating North. 223 | 224 | ### Wind Speed 225 | 226 | ```python 227 | sensor.wind_speed 228 | ``` 229 | 230 | Wind speed in meters per second. 231 | 232 | Weather HAT counts every half rotation and converts this to cm/s using the anemometer circumference and factor. 233 | 234 | It's updated depending on the update interval requested. 235 | 236 | ## Rain 237 | 238 | ```python 239 | sensor.rain 240 | ``` 241 | 242 | Rain amount in millimeters per second. 243 | 244 | Weather HAT counts every "tick" of the rain gauge (roughly .28mm) over the given update internal and converts this into mm/sec. 245 | 246 | ### Total Rain 247 | 248 | ```python 249 | sensor.rain_total 250 | ``` 251 | 252 | Total rain amount in millimeters for the current update period. 253 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') 3 | CONFIG_FILE=config.txt 4 | CONFIG_DIR="/boot/firmware" 5 | DATESTAMP=$(date "+%Y-%m-%d-%H-%M-%S") 6 | CONFIG_BACKUP=false 7 | APT_HAS_UPDATED=false 8 | RESOURCES_TOP_DIR="$HOME/Pimoroni" 9 | VENV_BASH_SNIPPET="$RESOURCES_TOP_DIR/auto_venv.sh" 10 | VENV_DIR="$HOME/.virtualenvs/pimoroni" 11 | USAGE="./install.sh (--unstable)" 12 | POSITIONAL_ARGS=() 13 | FORCE=false 14 | UNSTABLE=false 15 | PYTHON="python" 16 | CMD_ERRORS=false 17 | 18 | 19 | user_check() { 20 | if [ "$(id -u)" -eq 0 ]; then 21 | fatal "Script should not be run as root. Try './install.sh'\n" 22 | fi 23 | } 24 | 25 | confirm() { 26 | if $FORCE; then 27 | true 28 | else 29 | read -r -p "$1 [y/N] " response < /dev/tty 30 | if [[ $response =~ ^(yes|y|Y)$ ]]; then 31 | true 32 | else 33 | false 34 | fi 35 | fi 36 | } 37 | 38 | success() { 39 | echo -e "$(tput setaf 2)$1$(tput sgr0)" 40 | } 41 | 42 | inform() { 43 | echo -e "$(tput setaf 6)$1$(tput sgr0)" 44 | } 45 | 46 | warning() { 47 | echo -e "$(tput setaf 1)⚠ WARNING:$(tput sgr0) $1" 48 | } 49 | 50 | fatal() { 51 | echo -e "$(tput setaf 1)⚠ FATAL:$(tput sgr0) $1" 52 | exit 1 53 | } 54 | 55 | find_config() { 56 | if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then 57 | CONFIG_DIR="/boot" 58 | if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then 59 | fatal "Could not find $CONFIG_FILE!" 60 | fi 61 | fi 62 | inform "Using $CONFIG_FILE in $CONFIG_DIR" 63 | } 64 | 65 | venv_bash_snippet() { 66 | inform "Checking for $VENV_BASH_SNIPPET\n" 67 | if [ ! -f "$VENV_BASH_SNIPPET" ]; then 68 | inform "Creating $VENV_BASH_SNIPPET\n" 69 | mkdir -p "$RESOURCES_TOP_DIR" 70 | cat << EOF > "$VENV_BASH_SNIPPET" 71 | # Add "source $VENV_BASH_SNIPPET" to your ~/.bashrc to activate 72 | # the Pimoroni virtual environment automagically! 73 | VENV_DIR="$VENV_DIR" 74 | if [ ! -f \$VENV_DIR/bin/activate ]; then 75 | printf "Creating user Python environment in \$VENV_DIR, please wait...\n" 76 | mkdir -p \$VENV_DIR 77 | python3 -m venv --system-site-packages \$VENV_DIR 78 | fi 79 | printf " ↓ ↓ ↓ ↓ Hello, we've activated a Python venv for you. To exit, type \"deactivate\".\n" 80 | source \$VENV_DIR/bin/activate 81 | EOF 82 | fi 83 | } 84 | 85 | venv_check() { 86 | PYTHON_BIN=$(which "$PYTHON") 87 | if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then 88 | printf "This script should be run in a virtual Python environment.\n" 89 | if confirm "Would you like us to create and/or use a default one?"; then 90 | printf "\n" 91 | if [ ! -f "$VENV_DIR/bin/activate" ]; then 92 | inform "Creating a new virtual Python environment in $VENV_DIR, please wait...\n" 93 | mkdir -p "$VENV_DIR" 94 | /usr/bin/python3 -m venv "$VENV_DIR" --system-site-packages 95 | venv_bash_snippet 96 | # shellcheck disable=SC1091 97 | source "$VENV_DIR/bin/activate" 98 | else 99 | inform "Activating existing virtual Python environment in $VENV_DIR\n" 100 | printf "source \"%s/bin/activate\"\n" "$VENV_DIR" 101 | # shellcheck disable=SC1091 102 | source "$VENV_DIR/bin/activate" 103 | fi 104 | else 105 | printf "\n" 106 | fatal "Please create and/or activate a virtual Python environment and try again!\n" 107 | fi 108 | fi 109 | printf "\n" 110 | } 111 | 112 | check_for_error() { 113 | if [ $? -ne 0 ]; then 114 | CMD_ERRORS=true 115 | warning "^^^ 😬 previous command did not exit cleanly!" 116 | fi 117 | } 118 | 119 | function do_config_backup { 120 | if [ ! $CONFIG_BACKUP == true ]; then 121 | CONFIG_BACKUP=true 122 | FILENAME="config.preinstall-$LIBRARY_NAME-$DATESTAMP.txt" 123 | inform "Backing up $CONFIG_DIR/$CONFIG_FILE to $CONFIG_DIR/$FILENAME\n" 124 | sudo cp "$CONFIG_DIR/$CONFIG_FILE" "$CONFIG_DIR/$FILENAME" 125 | mkdir -p "$RESOURCES_TOP_DIR/config-backups/" 126 | cp $CONFIG_DIR/$CONFIG_FILE "$RESOURCES_TOP_DIR/config-backups/$FILENAME" 127 | if [ -f "$UNINSTALLER" ]; then 128 | echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG_DIR/$CONFIG_FILE" >> "$UNINSTALLER" 129 | fi 130 | fi 131 | } 132 | 133 | function apt_pkg_install { 134 | PACKAGES_NEEDED=() 135 | PACKAGES_IN=("$@") 136 | # Check the list of packages and only run update/install if we need to 137 | for ((i = 0; i < ${#PACKAGES_IN[@]}; i++)); do 138 | PACKAGE="${PACKAGES_IN[$i]}" 139 | if [ "$PACKAGE" == "" ]; then continue; fi 140 | printf "Checking for %s\n" "$PACKAGE" 141 | dpkg -L "$PACKAGE" > /dev/null 2>&1 142 | if [ "$?" == "1" ]; then 143 | PACKAGES_NEEDED+=("$PACKAGE") 144 | fi 145 | done 146 | PACKAGES="${PACKAGES_NEEDED[*]}" 147 | if ! [ "$PACKAGES" == "" ]; then 148 | printf "\n" 149 | inform "Installing missing packages: $PACKAGES" 150 | if [ ! $APT_HAS_UPDATED ]; then 151 | sudo apt update 152 | APT_HAS_UPDATED=true 153 | fi 154 | # shellcheck disable=SC2086 155 | sudo apt install -y $PACKAGES 156 | check_for_error 157 | if [ -f "$UNINSTALLER" ]; then 158 | echo "apt uninstall -y $PACKAGES" >> "$UNINSTALLER" 159 | fi 160 | fi 161 | } 162 | 163 | function pip_pkg_install { 164 | # A null Keyring prevents pip stalling in the background 165 | PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --prefer-binary --upgrade "$@" 166 | check_for_error 167 | } 168 | 169 | function pip_requirements_install { 170 | # A null Keyring prevents pip stalling in the background 171 | PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --prefer-binary -r "$@" 172 | check_for_error 173 | } 174 | 175 | while [[ $# -gt 0 ]]; do 176 | K="$1" 177 | case $K in 178 | -u|--unstable) 179 | UNSTABLE=true 180 | shift 181 | ;; 182 | -f|--force) 183 | FORCE=true 184 | shift 185 | ;; 186 | -p|--python) 187 | PYTHON=$2 188 | shift 189 | shift 190 | ;; 191 | *) 192 | if [[ $1 == -* ]]; then 193 | printf "Unrecognised option: %s\n" "$1"; 194 | printf "Usage: %s\n" "$USAGE"; 195 | exit 1 196 | fi 197 | POSITIONAL_ARGS+=("$1") 198 | shift 199 | esac 200 | done 201 | 202 | printf "Installing %s...\n\n" "$LIBRARY_NAME" 203 | 204 | user_check 205 | venv_check 206 | 207 | if [ ! -f "$(which "$PYTHON")" ]; then 208 | fatal "Python path %s not found!\n" "$PYTHON" 209 | fi 210 | 211 | PYTHON_VER=$($PYTHON --version) 212 | 213 | inform "Checking Dependencies. Please wait..." 214 | 215 | # Install toml and try to read pyproject.toml into bash variables 216 | 217 | pip_pkg_install toml 218 | 219 | CONFIG_VARS=$( 220 | $PYTHON - < "$UNINSTALLER" 263 | printf "It's recommended you run these steps manually.\n" 264 | printf "If you want to run the full script, open it in\n" 265 | printf "an editor and remove 'exit 1' from below.\n" 266 | exit 1 267 | source $VIRTUAL_ENV/bin/activate 268 | EOF 269 | 270 | printf "\n" 271 | 272 | inform "Installing for $PYTHON_VER...\n" 273 | 274 | # Install apt packages from pyproject.toml / tool.pimoroni.apt_packages 275 | apt_pkg_install "${APT_PACKAGES[@]}" 276 | 277 | printf "\n" 278 | 279 | if $UNSTABLE; then 280 | warning "Installing unstable library from source.\n" 281 | pip_pkg_install . 282 | else 283 | inform "Installing stable library from pypi.\n" 284 | pip_pkg_install "$LIBRARY_NAME" 285 | fi 286 | 287 | # shellcheck disable=SC2181 # One of two commands run, depending on --unstable flag 288 | if [ $? -eq 0 ]; then 289 | success "Done!\n" 290 | echo "$PYTHON -m pip uninstall $LIBRARY_NAME" >> "$UNINSTALLER" 291 | fi 292 | 293 | find_config 294 | 295 | printf "\n" 296 | 297 | # Run the setup commands from pyproject.toml / tool.pimoroni.commands 298 | 299 | inform "Running setup commands...\n" 300 | for ((i = 0; i < ${#SETUP_CMDS[@]}; i++)); do 301 | CMD="${SETUP_CMDS[$i]}" 302 | # Attempt to catch anything that touches config.txt and trigger a backup 303 | if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG_DIR/$CONFIG_FILE"* ]] || [[ "$CMD" == *"\$CONFIG_DIR/\$CONFIG_FILE"* ]]; then 304 | do_config_backup 305 | fi 306 | if [[ ! "$CMD" == printf* ]]; then 307 | printf "Running: \"%s\"\n" "$CMD" 308 | fi 309 | eval "$CMD" 310 | check_for_error 311 | done 312 | 313 | printf "\n" 314 | 315 | # Add the config.txt entries from pyproject.toml / tool.pimoroni.configtxt 316 | 317 | for ((i = 0; i < ${#CONFIG_TXT[@]}; i++)); do 318 | CONFIG_LINE="${CONFIG_TXT[$i]}" 319 | if ! [ "$CONFIG_LINE" == "" ]; then 320 | do_config_backup 321 | inform "Adding $CONFIG_LINE to $CONFIG_DIR/$CONFIG_FILE" 322 | sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG_DIR/$CONFIG_FILE 323 | if ! grep -q "^$CONFIG_LINE" $CONFIG_DIR/$CONFIG_FILE; then 324 | printf "%s \n" "$CONFIG_LINE" | sudo tee --append $CONFIG_DIR/$CONFIG_FILE 325 | fi 326 | fi 327 | done 328 | 329 | printf "\n" 330 | 331 | # Just a straight copy of the examples/ dir into ~/Pimoroni/board/examples 332 | 333 | if [ -d "examples" ]; then 334 | if confirm "Would you like to copy examples to $RESOURCES_DIR?"; then 335 | inform "Copying examples to $RESOURCES_DIR" 336 | cp -r examples/ "$RESOURCES_DIR" 337 | echo "rm -r $RESOURCES_DIR" >> "$UNINSTALLER" 338 | success "Done!" 339 | fi 340 | fi 341 | 342 | printf "\n" 343 | 344 | if [ -f "requirements-examples.txt" ]; then 345 | if confirm "Would you like to install example dependencies?"; then 346 | inform "Installing dependencies from requirements-examples.txt..." 347 | pip_requirements_install requirements-examples.txt 348 | fi 349 | fi 350 | 351 | printf "\n" 352 | 353 | # Use pdoc to generate basic documentation from the installed module 354 | 355 | if confirm "Would you like to generate documentation?"; then 356 | inform "Installing pdoc. Please wait..." 357 | pip_pkg_install pdoc 358 | inform "Generating documentation.\n" 359 | if $PYTHON -m pdoc "$LIBRARY_NAME" -o "$RESOURCES_DIR/docs" > /dev/null; then 360 | inform "Documentation saved to $RESOURCES_DIR/docs" 361 | success "Done!" 362 | else 363 | warning "Error: Failed to generate documentation." 364 | fi 365 | fi 366 | 367 | printf "\n" 368 | 369 | if [ "$CMD_ERRORS" = true ]; then 370 | warning "One or more setup commands appear to have failed." 371 | printf "This might prevent things from working properly.\n" 372 | printf "Make sure your OS is up to date and try re-running this installer.\n" 373 | printf "If things still don't work, report this or find help at %s.\n\n" "$GITHUB_URL" 374 | else 375 | success "\nAll done!" 376 | fi 377 | 378 | printf "If this is your first time installing you should reboot for hardware changes to take effect.\n" 379 | printf "Find uninstall steps in %s\n\n" "$UNINSTALLER" 380 | 381 | if [ "$CMD_ERRORS" = true ]; then 382 | exit 1 383 | else 384 | exit 0 385 | fi 386 | -------------------------------------------------------------------------------- /examples/weather.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import math 3 | import pathlib 4 | import select 5 | import time 6 | from datetime import timedelta 7 | 8 | import gpiod 9 | import gpiodevice 10 | import st7789 11 | import yaml 12 | from fonts.ttf import ManropeBold as UserFont 13 | from gpiod.line import Bias, Edge 14 | from PIL import Image, ImageDraw, ImageFont 15 | 16 | import weatherhat 17 | from weatherhat import history 18 | 19 | FPS = 10 20 | 21 | BUTTONS = [5, 6, 16, 24] 22 | LABELS = ["A", "B", "X", "Y"] 23 | 24 | DISPLAY_WIDTH = 240 25 | DISPLAY_HEIGHT = 240 26 | SPI_SPEED_MHZ = 80 27 | 28 | COLOR_WHITE = (255, 255, 255) 29 | COLOR_BLUE = (31, 137, 251) 30 | COLOR_GREEN = (99, 255, 124) 31 | COLOR_YELLOW = (254, 219, 82) 32 | COLOR_RED = (247, 0, 63) 33 | COLOR_BLACK = (0, 0, 0) 34 | COLOR_GREY = (100, 100, 100) 35 | 36 | # We can compensate for the heat of the Pi and other environmental conditions using a simple offset. 37 | # Change this number to adjust temperature compensation! 38 | OFFSET = -7.5 39 | 40 | 41 | class View: 42 | def __init__(self, image): 43 | self._image = image 44 | self._draw = ImageDraw.Draw(image) 45 | 46 | self.font_large = ImageFont.truetype(UserFont, 80) 47 | self.font = ImageFont.truetype(UserFont, 50) 48 | self.font_medium = ImageFont.truetype(UserFont, 44) 49 | self.font_small = ImageFont.truetype(UserFont, 28) 50 | 51 | @property 52 | def canvas_width(self): 53 | return self._image.size[0] 54 | 55 | @property 56 | def canvas_height(self): 57 | return self._image.size[1] 58 | 59 | def button_a(self): 60 | return False 61 | 62 | def button_b(self): 63 | return False 64 | 65 | def button_x(self): 66 | return False 67 | 68 | def button_y(self): 69 | return False 70 | 71 | def update(self): 72 | pass 73 | 74 | def render(self): 75 | self.clear() 76 | 77 | def clear(self): 78 | self._draw.rectangle((0, 0, self.canvas_width, self.canvas_height), (0, 0, 0)) 79 | 80 | 81 | class SensorView(View): 82 | title = "" 83 | GRAPH_BAR_WIDTH = 20 84 | 85 | def __init__(self, image, sensordata, settings=None): 86 | View.__init__(self, image) 87 | self._data = sensordata 88 | self._settings = settings 89 | 90 | def blend(self, a, b, factor): 91 | blend_b = factor 92 | blend_a = 1.0 - factor 93 | return tuple([int((a[i] * blend_a) + (b[i] * blend_b)) for i in range(3)]) 94 | 95 | def heading(self, data, units): 96 | if data < 100: 97 | data = "{:0.1f}".format(data) 98 | else: 99 | data = "{:0.0f}".format(data) 100 | 101 | _, _, tw, th = self._draw.textbbox((0, 0), data, self.font_large) 102 | 103 | self._draw.text( 104 | (0, 32), 105 | data, 106 | font=self.font_large, 107 | fill=COLOR_WHITE, 108 | anchor="lm" 109 | ) 110 | 111 | self._draw.text( 112 | (tw, 64), 113 | units, 114 | font=self.font_medium, 115 | fill=COLOR_WHITE, 116 | anchor="lb" 117 | ) 118 | 119 | def footer(self, label): 120 | self._draw.text((int(self.canvas_width / 2), self.canvas_height - 30), label, font=self.font_medium, fill=COLOR_GREY, anchor="mm") 121 | 122 | def graph(self, values, graph_x=0, graph_y=0, width=None, height=None, vmin=0, vmax=1.0, bar_width=2, colors=None): 123 | if not len(values): 124 | return 125 | 126 | if width is None: 127 | width = self.canvas_width 128 | 129 | if height is None: 130 | height = self.canvas_height 131 | 132 | if colors is None: 133 | # Blue Teal Green Yellow Red 134 | colors = [(0, 0, 255), (0, 255, 255), (0, 255, 0), (255, 255, 0), (255, 0, 0)] 135 | 136 | vrange = vmax - vmin 137 | vstep = float(height) / vrange 138 | 139 | if vmin >= 0: 140 | midpoint_y = height 141 | else: 142 | midpoint_y = vmax * vstep 143 | self._draw.line((graph_x, graph_y + midpoint_y, graph_x + width, graph_y + midpoint_y), fill=COLOR_GREY) 144 | 145 | max_values = int(width / bar_width) 146 | 147 | values = [entry.value for entry in values[-max_values:]] 148 | 149 | for i, v in enumerate(values): 150 | v = min(vmax, max(vmin, v)) 151 | 152 | offset_y = graph_y 153 | 154 | if vmin < 0: 155 | bar_height = midpoint_y * float(v) / float(vmax) 156 | else: 157 | bar_height = midpoint_y * float(v - vmin) / float(vmax - vmin) 158 | 159 | if v < 0: 160 | offset_y += midpoint_y 161 | bar_height = (height - midpoint_y) * float(abs(v)) / abs(vmin) 162 | 163 | color = float(v - vmin) / float(vmax - vmin) * (len(colors) - 1) 164 | color_idx = int(color) # The integer part of color becomes our index into the colors array 165 | blend = color - color_idx # The fractional part forms the blend amount between the two colours 166 | bar_color = colors[color_idx] 167 | if color_idx < len(colors) - 1: 168 | bar_color = self.blend(colors[color_idx], colors[color_idx + 1], blend) 169 | bar_color = bar_color 170 | 171 | x = (i * bar_width) 172 | 173 | if v < 0: 174 | self._draw.rectangle(( 175 | graph_x + x, offset_y, 176 | graph_x + x + int(bar_width / 2), offset_y + bar_height 177 | ), fill=bar_color) 178 | else: 179 | self._draw.rectangle(( 180 | graph_x + x, offset_y + midpoint_y - bar_height, 181 | graph_x + x + int(bar_width / 2), offset_y + midpoint_y 182 | ), fill=bar_color) 183 | 184 | 185 | class MainView(SensorView): 186 | """Main Overview. 187 | 188 | Displays weather summary and navigation hints. 189 | 190 | """ 191 | 192 | title = "Overview" 193 | 194 | def draw_info(self, x, y, color, label, data, desc, right=False, vmin=0, vmax=20, graph_mode=False): 195 | w = 200 196 | o_x = 0 if right else 40 197 | 198 | if graph_mode: 199 | vmax = max(vmax, max([h.value for h in data])) # auto ranging? 200 | self.graph(data, x + o_x + 30, y + 20, 180, 64, vmin=vmin, vmax=vmax, bar_width=20, colors=[color]) 201 | else: 202 | if isinstance(data, list): 203 | if len(data) > 0: 204 | data = data[-1].value 205 | else: 206 | data = 0 207 | 208 | if data < 100: 209 | data = "{:0.1f}".format(data) 210 | else: 211 | data = "{:0.0f}".format(data) 212 | 213 | self._draw.text( 214 | (x + w + o_x, y + 20 + 32), # Position is the right, center of the text 215 | data, 216 | font=self.font_large, 217 | fill=color, 218 | anchor="rm" # Using "rm" stops text jumping vertically 219 | ) 220 | 221 | self._draw.text( 222 | (x + w + o_x, y + 90 + 40), 223 | desc, 224 | font=self.font, 225 | fill=COLOR_WHITE, 226 | anchor="rb" 227 | ) 228 | label_img = Image.new("RGB", (130, 40)) 229 | label_draw = ImageDraw.Draw(label_img) 230 | label_draw.text((0, 40) if right else (0, 0), label, font=self.font_medium, fill=COLOR_GREY, anchor="lb" if right else "lt") 231 | label_img = label_img.rotate(90, expand=True) 232 | if right: 233 | self._image.paste(label_img, (x + w, y)) 234 | else: 235 | self._image.paste(label_img, (x, y)) 236 | 237 | def render(self): 238 | SensorView.render(self) 239 | self.render_graphs() 240 | 241 | def render_graphs(self, graph_mode=False): 242 | self.draw_info(0, 0, (20, 20, 220), "RAIN", self._data.rain_mm_sec.history(), "mm/s", vmax=self._settings.maximum_rain_mm, graph_mode=graph_mode) 243 | self.draw_info(0, 150, (20, 20, 220), "PRES", self._data.pressure.history(), "hPa", graph_mode=graph_mode) 244 | self.draw_info(0, 300, (20, 100, 220), "TEMP", self._data.temperature.history(), "°C", graph_mode=graph_mode, vmin=self._settings.minimum_temperature, vmax=self._settings.maximum_temperature) 245 | 246 | x = int(self.canvas_width / 2) 247 | self.draw_info(x, 0, (220, 20, 220), "WIND", self._data.wind_speed.history(), "m/s", right=True, graph_mode=graph_mode) 248 | self.draw_info(x, 150, (220, 100, 20), "LIGHT", self._data.lux.history(), "lux", right=True, graph_mode=graph_mode) 249 | self.draw_info(x, 300, (10, 10, 220), "HUM", self._data.relative_humidity.history(), "%rh", right=True, graph_mode=graph_mode) 250 | 251 | 252 | class MainViewGraph(MainView): 253 | title = "Overview: Graphs" 254 | 255 | def render(self): 256 | SensorView.render(self) 257 | self.render_graphs(graph_mode=True) 258 | 259 | 260 | class WindDirectionView(SensorView): 261 | """Wind Direction.""" 262 | 263 | title = "Wind" 264 | metric = "m/sec" 265 | 266 | def __init__(self, image, sensordata, settings=None): 267 | SensorView.__init__(self, image, sensordata, settings) 268 | 269 | def render(self): 270 | SensorView.render(self) 271 | ox = self.canvas_width / 2 272 | oy = 40 + ((self.canvas_height - 60) / 2) 273 | needle = self._data.needle 274 | speed_ms = self._data.wind_speed.average(60) 275 | # gust_ms = self._data.wind_speed.gust() 276 | compass_direction = self._data.wind_direction.average_compass() 277 | 278 | radius = 80 279 | speed_max = 4.4 # m/s 280 | speed = min(speed_ms, speed_max) 281 | speed /= float(speed_max) 282 | 283 | arrow_radius_min = 20 284 | arrow_radius_max = 60 285 | arrow_radius = (speed * (arrow_radius_max - arrow_radius_min)) + arrow_radius_min 286 | arrow_angle = math.radians(130) 287 | 288 | tx, ty = ox + math.sin(needle) * (radius - arrow_radius), oy - math.cos(needle) * (radius - arrow_radius) 289 | ax, ay = ox + math.sin(needle) * (radius - arrow_radius), oy - math.cos(needle) * (radius - arrow_radius) 290 | 291 | arrow_xy_a = ax + math.sin(needle - arrow_angle) * arrow_radius, ay - math.cos(needle - arrow_angle) * arrow_radius 292 | arrow_xy_b = ax + math.sin(needle) * arrow_radius, ay - math.cos(needle) * arrow_radius 293 | arrow_xy_c = ax + math.sin(needle + arrow_angle) * arrow_radius, ay - math.cos(needle + arrow_angle) * arrow_radius 294 | 295 | # Compass red end 296 | self._draw.line(( 297 | ox, 298 | oy, 299 | tx, 300 | ty 301 | ), (255, 0, 0), 5) 302 | 303 | # Compass white end 304 | """ 305 | self._draw.line(( 306 | ox, 307 | oy, 308 | ox + math.sin(needle - math.pi) * radius, 309 | oy - math.cos(needle - math.pi) * radius 310 | ), (255, 255, 255), 5) 311 | """ 312 | 313 | self._draw.polygon([arrow_xy_a, arrow_xy_b, arrow_xy_c], fill=(255, 0, 0)) 314 | 315 | if self._settings.wind_trails: 316 | trails = 40 317 | trail_length = len(self._data.needle_trail) 318 | for i, p in enumerate(self._data.needle_trail): 319 | # r = radius 320 | r = radius + trails - (float(i) / trail_length * trails) 321 | x = ox + math.sin(p) * r 322 | y = oy - math.cos(p) * r 323 | 324 | self._draw.ellipse((x - 2, y - 2, x + 2, y + 2), (int(255 / trail_length * i), 0, 0)) 325 | 326 | radius += 60 327 | for direction, name in weatherhat.wind_degrees_to_cardinal.items(): 328 | p = math.radians(direction) 329 | x = ox + math.sin(p) * radius 330 | y = oy - math.cos(p) * radius 331 | 332 | name = "".join([word[0] for word in name.split(" ")]) 333 | _, _, tw, th = self._draw.textbbox((0, 0), name, font=self.font_small) 334 | x -= tw / 2 335 | y -= th / 2 336 | self._draw.text((x, y), name, font=self.font_small, fill=COLOR_GREY) 337 | 338 | self.heading(speed_ms, self.metric) 339 | self.footer(self.title.upper()) 340 | 341 | direction_text = "".join([word[0] for word in compass_direction.split(" ")]) 342 | 343 | self._draw.text( 344 | (self.canvas_width, 32), 345 | direction_text, 346 | font=self.font_large, 347 | fill=COLOR_WHITE, 348 | anchor="rm" 349 | ) 350 | 351 | 352 | class WindSpeedView(SensorView): 353 | """Wind Speed.""" 354 | 355 | title = "WIND" 356 | metric = "m/s" 357 | 358 | def render(self): 359 | SensorView.render(self) 360 | self.heading( 361 | self._data.wind_speed.latest().value, 362 | self.metric 363 | ) 364 | self.footer(self.title.upper()) 365 | 366 | self.graph( 367 | self._data.wind_speed.history(), 368 | graph_x=4, 369 | graph_y=70, 370 | width=self.canvas_width, 371 | height=self.canvas_height - 130, 372 | vmin=self._settings.minimum_wind_ms, 373 | vmax=self._settings.maximum_wind_ms, 374 | bar_width=self.GRAPH_BAR_WIDTH 375 | ) 376 | 377 | 378 | class RainView(SensorView): 379 | """Rain.""" 380 | 381 | title = "Rain" 382 | metric = "mm/s" 383 | 384 | def render(self): 385 | SensorView.render(self) 386 | self.heading( 387 | self._data.rain_mm_sec.latest().value, 388 | self.metric 389 | ) 390 | self.footer(self.title.upper()) 391 | 392 | self.graph( 393 | self._data.rain_mm_sec.history(), 394 | graph_x=4, 395 | graph_y=70, 396 | width=self.canvas_width, 397 | height=self.canvas_height - 130, 398 | vmin=self._settings.minimum_rain_mm, 399 | vmax=self._settings.maximum_rain_mm, 400 | bar_width=self.GRAPH_BAR_WIDTH 401 | ) 402 | 403 | 404 | class TemperatureView(SensorView): 405 | """Temperature.""" 406 | 407 | title = "TEMP" 408 | metric = "°C" 409 | 410 | def render(self): 411 | SensorView.render(self) 412 | self.heading( 413 | self._data.temperature.latest().value, 414 | self.metric 415 | ) 416 | self.footer(self.title.upper()) 417 | 418 | self.graph( 419 | self._data.temperature.history(), 420 | graph_x=4, 421 | graph_y=70, 422 | width=self.canvas_width, 423 | height=self.canvas_height - 130, 424 | vmin=self._settings.minimum_temperature, 425 | vmax=self._settings.maximum_temperature, 426 | bar_width=self.GRAPH_BAR_WIDTH 427 | ) 428 | 429 | 430 | class LightView(SensorView): 431 | """Light.""" 432 | 433 | title = "Light" 434 | metric = "lux" 435 | 436 | def render(self): 437 | SensorView.render(self) 438 | self.heading( 439 | self._data.lux.latest().value, 440 | self.metric 441 | ) 442 | self.footer(self.title.upper()) 443 | 444 | self.graph( 445 | self._data.lux.history(int(self.canvas_width / self.GRAPH_BAR_WIDTH)), 446 | graph_x=4, 447 | graph_y=70, 448 | width=self.canvas_width, 449 | height=self.canvas_height - 130, 450 | vmin=self._settings.minimum_lux, 451 | vmax=self._settings.maximum_lux, 452 | bar_width=self.GRAPH_BAR_WIDTH 453 | ) 454 | 455 | 456 | class PressureView(SensorView): 457 | """Pressure.""" 458 | 459 | title = "PRESSURE" 460 | metric = "hPa" 461 | 462 | def render(self): 463 | SensorView.render(self) 464 | self.heading( 465 | self._data.pressure.latest().value, 466 | self.metric 467 | ) 468 | self.footer(self.title.upper()) 469 | 470 | self.graph( 471 | self._data.pressure.history(int(self.canvas_width / self.GRAPH_BAR_WIDTH)), 472 | graph_x=4, 473 | graph_y=70, 474 | width=self.canvas_width, 475 | height=self.canvas_height - 130, 476 | vmin=self._settings.minimum_pressure, 477 | vmax=self._settings.maximum_pressure, 478 | bar_width=self.GRAPH_BAR_WIDTH 479 | ) 480 | 481 | 482 | class HumidityView(SensorView): 483 | """Pressure.""" 484 | 485 | title = "Humidity" 486 | metric = "%rh" 487 | 488 | def render(self): 489 | SensorView.render(self) 490 | self.heading( 491 | self._data.relative_humidity.latest().value, 492 | self.metric 493 | ) 494 | self.footer(self.title.upper()) 495 | 496 | self.graph( 497 | self._data.relative_humidity.history(int(self.canvas_width / self.GRAPH_BAR_WIDTH)), 498 | graph_x=4, 499 | graph_y=70, 500 | width=self.canvas_width, 501 | height=self.canvas_height - 130, 502 | vmin=0, 503 | vmax=100, 504 | bar_width=self.GRAPH_BAR_WIDTH 505 | ) 506 | 507 | 508 | class ViewController: 509 | def __init__(self, views): 510 | self.views = views 511 | self._current_view = 0 512 | self._current_subview = 0 513 | 514 | #GPIO.setmode(GPIO.BCM) 515 | #GPIO.setwarnings(False) 516 | #GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP) 517 | 518 | #for pin in BUTTONS: 519 | # GPIO.add_event_detect(pin, GPIO.FALLING, self.handle_button, bouncetime=200) 520 | 521 | config = {} 522 | for pin in BUTTONS: 523 | config[pin] = gpiod.LineSettings( 524 | edge_detection=Edge.FALLING, 525 | bias=Bias.PULL_UP, 526 | debounce_period=timedelta(milliseconds=20) 527 | ) 528 | 529 | chip = gpiodevice.find_chip_by_platform() 530 | self._buttons = chip.request_lines(consumer="LTR559", config=config) 531 | self._poll = select.poll() 532 | self._poll.register(self._buttons.fd, select.POLLIN) 533 | 534 | def handle_button(self, pin): 535 | index = BUTTONS.index(pin) 536 | label = LABELS[index] 537 | 538 | if label == "A": # Select View 539 | self.button_a() 540 | 541 | if label == "B": 542 | self.button_b() 543 | 544 | if label == "X": 545 | self.button_x() 546 | 547 | if label == "Y": 548 | self.button_y() 549 | 550 | @property 551 | def home(self): 552 | return self._current_view == 0 and self._current_subview == 0 553 | 554 | def next_subview(self): 555 | view = self.views[self._current_view] 556 | if isinstance(view, tuple): 557 | self._current_subview += 1 558 | self._current_subview %= len(view) 559 | 560 | def next_view(self): 561 | self._current_subview = 0 562 | self._current_view += 1 563 | self._current_view %= len(self.views) 564 | 565 | def prev_view(self): 566 | self._current_subview = 0 567 | self._current_view -= 1 568 | self._current_view %= len(self.views) 569 | 570 | def get_current_view(self): 571 | view = self.views[self._current_view] 572 | if isinstance(view, tuple): 573 | view = view[self._current_subview] 574 | 575 | return view 576 | 577 | @property 578 | def view(self): 579 | return self.get_current_view() 580 | 581 | def update(self): 582 | if self._poll.poll(10): 583 | for event in self._buttons.read_edge_events(): 584 | self.handle_button(event.line_offset) 585 | self.view.update() 586 | 587 | def render(self): 588 | self.view.render() 589 | 590 | def button_a(self): 591 | if not self.view.button_a(): 592 | self.next_view() 593 | 594 | def button_b(self): 595 | self.view.button_b() 596 | 597 | def button_x(self): 598 | if not self.view.button_x(): 599 | self.next_subview() 600 | return True 601 | return True 602 | 603 | def button_y(self): 604 | return self.view.button_y() 605 | 606 | 607 | class Config: 608 | """Class to hold weather UI settings.""" 609 | def __init__(self, settings_file="settings.yml"): 610 | self._file = pathlib.Path(settings_file) 611 | 612 | self._last_save = None 613 | 614 | # Wind Settings 615 | self.wind_trails = True 616 | 617 | # BME280 Settings 618 | self.minimum_temperature = -10 619 | self.maximum_temperature = 40 620 | 621 | self.minimum_pressure = 1000 622 | self.maximum_pressure = 1100 623 | 624 | self.minimum_lux = 100 625 | self.maximum_lux = 1000 626 | 627 | self.minimum_rain_mm = 0 628 | self.maximum_rain_mm = 10 629 | 630 | self.minimum_wind_ms = 0 631 | self.maximum_wind_ms = 40 632 | 633 | self.load() 634 | 635 | def load(self): 636 | if not self._file.is_file(): 637 | return False 638 | 639 | try: 640 | self._config = yaml.safe_load(open(self._file)) 641 | except yaml.parser.ParserError as e: 642 | raise yaml.parser.ParserError( 643 | "Error parsing settings file: {} ({})".format(self._file, e) 644 | ) 645 | 646 | @property 647 | def _config(self): 648 | options = {} 649 | for k, v in self.__dict__.items(): 650 | if not k.startswith("_"): 651 | options[k] = v 652 | return options 653 | 654 | @_config.setter 655 | def _config(self, config): 656 | for k, v in self.__dict__.items(): 657 | if k in config: 658 | setattr(self, k, config[k]) 659 | 660 | 661 | class SensorData: 662 | AVERAGE_SAMPLES = 120 663 | WIND_DIRECTION_AVERAGE_SAMPLES = 60 664 | COMPASS_TRAIL_SIZE = 120 665 | 666 | def __init__(self): 667 | self.sensor = weatherhat.WeatherHAT() 668 | 669 | self.temperature = history.History() 670 | 671 | self.pressure = history.History() 672 | 673 | self.humidity = history.History() 674 | self.relative_humidity = history.History() 675 | self.dewpoint = history.History() 676 | 677 | self.lux = history.History() 678 | 679 | self.wind_speed = history.WindSpeedHistory() 680 | self.wind_direction = history.WindDirectionHistory() 681 | 682 | self.rain_mm_sec = history.History() 683 | self.rain_total = 0 684 | 685 | # Track previous average values to give the compass a trail 686 | self.needle_trail = [] 687 | 688 | def update(self, interval=5.0): 689 | self.sensor.temperature_offset = OFFSET 690 | self.sensor.update(interval) 691 | 692 | self.temperature.append(self.sensor.temperature) 693 | 694 | self.pressure.append(self.sensor.pressure) 695 | 696 | self.humidity.append(self.sensor.humidity) 697 | self.relative_humidity.append(self.sensor.relative_humidity) 698 | self.dewpoint.append(self.sensor.dewpoint) 699 | 700 | self.lux.append(self.sensor.lux) 701 | 702 | if self.sensor.updated_wind_rain: 703 | self.rain_total = self.sensor.rain_total 704 | else: 705 | self.rain_total = 0 706 | 707 | self.wind_speed.append(self.sensor.wind_speed) 708 | self.wind_direction.append(self.sensor.wind_direction) 709 | 710 | self.rain_mm_sec.append(self.sensor.rain) 711 | 712 | self.needle = math.radians(self.wind_direction.average(self.WIND_DIRECTION_AVERAGE_SAMPLES)) 713 | self.needle_trail.append(self.needle) 714 | self.needle_trail = self.needle_trail[-self.COMPASS_TRAIL_SIZE:] 715 | 716 | 717 | def main(): 718 | display = st7789.ST7789( 719 | rotation=90, 720 | port=0, 721 | cs=1, 722 | dc=9, 723 | backlight=12, 724 | spi_speed_hz=SPI_SPEED_MHZ * 1000 * 1000 725 | ) 726 | image = Image.new("RGBA", (DISPLAY_WIDTH * 2, DISPLAY_HEIGHT * 2), color=(255, 255, 255)) 727 | sensordata = SensorData() 728 | settings = Config() 729 | viewcontroller = ViewController( 730 | ( 731 | ( 732 | MainView(image, sensordata, settings), 733 | MainViewGraph(image, sensordata, settings) 734 | ), 735 | ( 736 | WindDirectionView(image, sensordata, settings), 737 | WindSpeedView(image, sensordata, settings) 738 | ), 739 | RainView(image, sensordata, settings), 740 | LightView(image, sensordata, settings), 741 | ( 742 | TemperatureView(image, sensordata, settings), 743 | PressureView(image, sensordata, settings), 744 | HumidityView(image, sensordata, settings) 745 | ), 746 | ) 747 | ) 748 | 749 | while True: 750 | sensordata.update(interval=5.0) 751 | viewcontroller.update() 752 | viewcontroller.render() 753 | display.display(image.resize((DISPLAY_WIDTH, DISPLAY_HEIGHT)).convert("RGB")) 754 | time.sleep(1.0 / FPS) 755 | 756 | 757 | if __name__ == "__main__": 758 | main() 759 | --------------------------------------------------------------------------------