diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f3cfa5c..07620e3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,10 +19,10 @@ jobs: steps: - name: Checkout Code - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} @@ -35,7 +35,7 @@ jobs: make build - name: Upload Packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ${{ env.RELEASE_FILE }} - path: dist/ \ No newline at end of file + path: dist/ diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml index 4f17183..ac672a5 100644 --- a/.github/workflows/qa.yml +++ b/.github/workflows/qa.yml @@ -10,16 +10,15 @@ jobs: test: name: linting & spelling runs-on: ubuntu-latest - env: TERM: xterm-256color steps: - name: Checkout Code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Python '3,11' - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: '3.11' @@ -33,4 +32,8 @@ jobs: - name: Run Code Checks run: | - make check \ No newline at end of file + make check + + - name: Run Bash Code Checks + run: | + make shellcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4522974..6f8cff7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} @@ -37,4 +37,5 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | python -m pip install coveralls - coveralls --service=github \ No newline at end of file + coveralls --service=github + diff --git a/CHANGELOG.md b/CHANGELOG.md index 17675fd..9982246 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +1.0.0 +----- + +* Repackage to hatch/pyproject +* Port to gpiod (Pi 5 support) + 0.0.2 ----- diff --git a/Makefile b/Makefile index 9e0c15c..56cf0df 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,9 @@ endif @echo "deploy: build and upload to PyPi" @echo "tag: tag the repository with the current version\n" +version: + @hatch version + install: ./install.sh --unstable @@ -30,11 +33,14 @@ uninstall: dev-deps: python3 -m pip install -r requirements-dev.txt - sudo apt install dos2unix + sudo apt install dos2unix shellcheck check: @bash check.sh +shellcheck: + shellcheck *.sh + qa: tox -e qa @@ -44,7 +50,7 @@ pytest: nopost: @bash check.sh --nopost -tag: +tag: version git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" build: check diff --git a/README.md b/README.md index c1ebbc5..92b2ea1 100644 --- a/README.md +++ b/README.md @@ -5,41 +5,74 @@ [![PyPi Package](https://img.shields.io/pypi/v/weatherhat.svg)](https://pypi.python.org/pypi/weatherhat) [![Python Versions](https://img.shields.io/pypi/pyversions/weatherhat.svg)](https://pypi.python.org/pypi/weatherhat) -# Pre-requisites +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. -This library requires Python ≥3.6 so we'd recommend using it with Raspberry Pi OS Buster or later. +## Where to buy -You must enable: +* [Weather HAT](https://shop.pimoroni.com/products/weather-hat-only) +* [Weather HAT + Weather Sensors Kit](https://shop.pimoroni.com/products/weather-hat) -* i2c: `sudo raspi-config nonint do_i2c 0` -* spi: `sudo raspi-config nonint do_spi 0` +# Installing -You can optionally run `sudo raspi-config` or the graphical Raspberry Pi Configuration UI to enable interfaces. +We'd recommend using this library with Raspberry Pi OS Bookworm or later. It requires Python ≥3.7. -# Installing +## Full install (recommended): + +We've created an easy installation script that will install all pre-requisites and get your Weather HAT +up and running with minimal efforts. To run it, fire up Terminal which you'll find in Menu -> Accessories -> Terminal +on your Raspberry Pi desktop, as illustrated below: + +![Finding the terminal](http://get.pimoroni.com/resources/github-repo-terminal.png) + +In the new terminal window type the commands exactly as it appears below (check for typos) and follow the on-screen instructions: + +```bash +git clone https://github.com/pimoroni/weatherhat-python +cd weatherhat-python +./install.sh +``` + +**Note** Libraries will be installed in the "pimoroni" virtual environment, you will need to activate it to run examples: -Stable library from PyPi: +``` +source ~/.virtualenvs/pimoroni/bin/activate +``` + +## Development: + +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: + +```bash +git clone https://github.com/pimoroni/weatherhat-python +cd weatherhat-python +./install.sh --unstable +``` + +## Install stable library from PyPi and configure manually + +* Set up a virtual environment: `python3 -m venv --system-site-packages $HOME/.virtualenvs/pimoroni` +* Switch to the virtual environment: `source ~/.virtualenvs/pimoroni/bin/activate` +* Install the library: `pip install weatherhat` -* Just run `pip3 install weatherhat` +In some cases you may need to us `sudo` or install pip with: `sudo apt install python3-pip`. -In some cases you may need to use `sudo` or install pip with: `sudo apt install python3-pip` +This will not make any configuration changes, so you may also need to enable: -Latest/development library from GitHub: +* i2c: `sudo raspi-config nonint do_i2c 0` +* spi: `sudo raspi-config nonint do_spi 0` -* `git clone https://github.com/pimoroni/weatherhat-python` -* `cd weatherhat-python` -* `./install.sh --unstable` +You can optionally run `sudo raspi-config` or the graphical Raspberry Pi Configuration UI to enable interfaces. -Some of the examples use additional libraries. You can install them with: +Some of the examples have additional dependencies. You can install them with: ```bash -pip3 install fonts font-manrope pyyaml adafruit-io numpy +pip install fonts font-manrope pyyaml adafruit-io numpy pillow ``` -You may also need to install `libatlas-base-dev` +You may also need to install `libatlas-base-dev`: ``` -sudo apt-get install libatlas-base-dev +sudo apt install libatlas-base-dev ``` # Using The Library diff --git a/check.sh b/check.sh index 4ca0d17..38dfc3a 100644 --- a/check.sh +++ b/check.sh @@ -3,9 +3,10 @@ # This script handles some basic QA checks on the source NOPOST=$1 -LIBRARY_NAME=`hatch project metadata name` -LIBRARY_VERSION=`hatch version | awk -F "." '{print $1"."$2"."$3}'` -POST_VERSION=`hatch version | awk -F "." '{print substr($4,0,length($4))}'` +LIBRARY_NAME=$(hatch project metadata name) +LIBRARY_VERSION=$(hatch version | awk -F "." '{print $1"."$2"."$3}') +POST_VERSION=$(hatch version | awk -F "." '{print substr($4,0,length($4))}') +TERM=${TERM:="xterm-256color"} success() { echo -e "$(tput setaf 2)$1$(tput sgr0)" @@ -28,7 +29,7 @@ while [[ $# -gt 0 ]]; do ;; *) if [[ $1 == -* ]]; then - printf "Unrecognised option: $1\n"; + printf "Unrecognised option: %s\n" "$1"; exit 1 fi POSITIONAL_ARGS+=("$1") @@ -39,8 +40,7 @@ done inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" inform "Checking for trailing whitespace..." -grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO -if [[ $? -eq 0 ]]; then +if grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO; then warning "Trailing whitespace found!" exit 1 else @@ -49,8 +49,7 @@ fi printf "\n" inform "Checking for DOS line-endings..." -grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile -if [[ $? -eq 0 ]]; then +if grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile; then warning "DOS line-endings found!" exit 1 else @@ -59,8 +58,7 @@ fi printf "\n" inform "Checking CHANGELOG.md..." -cat CHANGELOG.md | grep ^${LIBRARY_VERSION} > /dev/null 2>&1 -if [[ $? -eq 1 ]]; then +if ! grep "^${LIBRARY_VERSION}" CHANGELOG.md > /dev/null 2>&1; then warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." exit 1 else @@ -69,8 +67,7 @@ fi printf "\n" inform "Checking for git tag ${LIBRARY_VERSION}..." -git tag -l | grep -E "${LIBRARY_VERSION}$" -if [[ $? -eq 1 ]]; then +if ! git tag -l | grep -E "${LIBRARY_VERSION}$"; then warning "Missing git tag for version ${LIBRARY_VERSION}" fi printf "\n" @@ -84,4 +81,4 @@ if [[ $NOPOST ]]; then else success "OK" fi -fi \ No newline at end of file +fi diff --git a/examples/buttons.py b/examples/buttons.py index 732ed8a..a2bca50 100644 --- a/examples/buttons.py +++ b/examples/buttons.py @@ -1,41 +1,47 @@ -import signal +import select +from datetime import timedelta -import RPi.GPIO as GPIO +import gpiod +import gpiodevice +from gpiod.line import Bias, Edge print("""buttons.py - Detect which button has been pressed This example should demonstrate how to: -1. set up RPi.GPIO to read buttons, +1. set up gpiod to read buttons, 2. determine which button has been pressed Press Ctrl+C to exit! """) +IP_PU_FE = gpiod.LineSettings(edge_detection=Edge.FALLING, bias=Bias.PULL_UP, debounce_period=timedelta(milliseconds=20)) + # The buttons on Weather HAT are connected to pins 5, 6, 16 and 24 -BUTTONS = [5, 6, 16, 24] +# They short to ground, so we must Bias them with the PULL_UP resistor +# and watch for a falling-edge. +BUTTONS = {5: IP_PU_FE, 6: IP_PU_FE, 16: IP_PU_FE, 24: IP_PU_FE} # These correspond to buttons A, B, X and Y respectively -LABELS = ['A', 'B', 'X', 'Y'] - -# Set up RPi.GPIO with the "BCM" numbering scheme -GPIO.setmode(GPIO.BCM) - -# Buttons connect to ground when pressed, so we should set them up -# with a "PULL UP", which weakly pulls the input signal to 3.3V. -GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP) +LABELS = {5: 'A', 6: 'B', 16: 'X', 24: 'Y'} +# Request the button pins from the gpiochip +chip = gpiodevice.find_chip_by_platform() +lines = chip.request_lines( + consumer="buttons.py", + config=BUTTONS + ) # "handle_button" will be called every time a button is pressed # It receives one argument: the associated input pin. def handle_button(pin): - label = LABELS[BUTTONS.index(pin)] + label = LABELS[pin] print("Button press detected on pin: {} label: {}".format(pin, label)) +# read_edge_events does not allow us to specify a timeout +# so we'll use poll to check if any events are waiting for us... +poll = select.poll() +poll.register(lines.fd, select.POLLIN) -# Loop through out buttons and attach the "handle_button" function to each -# We're watching the "FALLING" edge (transition from 3.3V to Ground) and -# picking a generous bouncetime of 100ms to smooth out button presses. -for pin in BUTTONS: - GPIO.add_event_detect(pin, GPIO.FALLING, handle_button, bouncetime=100) - -# Finally, since button handlers don't require a "while True" loop, -# we pause the script to prevent it exiting immediately. -signal.pause() \ No newline at end of file +# Poll for button events +while True: + if poll.poll(10): + for event in lines.read_edge_events(): + handle_button(event.line_offset) \ No newline at end of file diff --git a/examples/lcd.py b/examples/lcd.py index fffe60c..df71332 100644 --- a/examples/lcd.py +++ b/examples/lcd.py @@ -1,24 +1,26 @@ #!/usr/bin/env python3 -import ST7789 from fonts.ttf import ManropeBold as UserFont from PIL import Image, ImageDraw, ImageFont +from st7789 import ST7789 -print(""" +print( + """ lcd.py - Hello, World! example on the 1.54" LCD. Press Ctrl+C to exit! -""") +""" +) SPI_SPEED_MHZ = 80 # Create LCD class instance. -disp = ST7789.ST7789( +disp = ST7789( rotation=90, port=0, cs=1, dc=9, backlight=12, - spi_speed_hz=SPI_SPEED_MHZ * 1000 * 1000 + spi_speed_hz=SPI_SPEED_MHZ * 1000 * 1000, ) # Initialize display. @@ -29,7 +31,7 @@ HEIGHT = disp.height # New canvas to draw on. -img = Image.new('RGB', (WIDTH, HEIGHT), color=(0, 0, 0)) +img = Image.new("RGB", (WIDTH, HEIGHT), color=(0, 0, 0)) draw = ImageDraw.Draw(img) # Text settings. @@ -39,7 +41,7 @@ back_colour = (0, 170, 170) message = "Hello, World!" -size_x, size_y = draw.textsize(message, font) +_, _, size_x, size_y = draw.textbbox((0, 0), message, font) # Calculate text position x = (WIDTH - size_x) / 2 @@ -57,4 +59,4 @@ # Turn off backlight on control-c except KeyboardInterrupt: - disp.set_backlight(0) \ No newline at end of file + disp.set_backlight(0) diff --git a/examples/weather.py b/examples/weather.py index e11e37b..ea03f18 100644 --- a/examples/weather.py +++ b/examples/weather.py @@ -1,12 +1,16 @@ #!/usr/bin/env python3 import math import pathlib +import select import time +from datetime import timedelta -import RPi.GPIO as GPIO -import ST7789 +import gpiod +import gpiodevice +import st7789 import yaml from fonts.ttf import ManropeBold as UserFont +from gpiod.line import Bias, Edge from PIL import Image, ImageDraw, ImageFont import weatherhat @@ -94,7 +98,7 @@ def heading(self, data, units): else: data = "{:0.0f}".format(data) - tw, th = self._draw.textsize(data, self.font_large) + _, _, tw, th = self._draw.textbbox((0, 0), data, self.font_large) self._draw.text( (0, 32), @@ -195,7 +199,7 @@ def draw_info(self, x, y, color, label, data, desc, right=False, vmin=0, vmax=20 vmax = max(vmax, max([h.value for h in data])) # auto ranging? self.graph(data, x + o_x + 30, y + 20, 180, 64, vmin=vmin, vmax=vmax, bar_width=20, colors=[color]) else: - if type(data) is list: + if isinstance(data, list): if len(data) > 0: data = data[-1].value else: @@ -326,7 +330,7 @@ def render(self): y = oy - math.cos(p) * radius name = "".join([word[0] for word in name.split(" ")]) - tw, th = self._draw.textsize(name, font=self.font_small) + _, _, tw, th = self._draw.textbbox((0, 0), name, font=self.font_small) x -= tw / 2 y -= th / 2 self._draw.text((x, y), name, font=self.font_small, fill=COLOR_GREY) @@ -507,12 +511,25 @@ def __init__(self, views): self._current_view = 0 self._current_subview = 0 - GPIO.setmode(GPIO.BCM) - GPIO.setwarnings(False) - GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP) + #GPIO.setmode(GPIO.BCM) + #GPIO.setwarnings(False) + #GPIO.setup(BUTTONS, GPIO.IN, pull_up_down=GPIO.PUD_UP) + #for pin in BUTTONS: + # GPIO.add_event_detect(pin, GPIO.FALLING, self.handle_button, bouncetime=200) + + config = {} for pin in BUTTONS: - GPIO.add_event_detect(pin, GPIO.FALLING, self.handle_button, bouncetime=200) + config[pin] = gpiod.LineSettings( + edge_detection=Edge.FALLING, + bias=Bias.PULL_UP, + debounce_period=timedelta(milliseconds=20) + ) + + chip = gpiodevice.find_chip_by_platform() + self._buttons = chip.request_lines(consumer="LTR559", config=config) + self._poll = select.poll() + self._poll.register(self._buttons.fd, select.POLLIN) def handle_button(self, pin): index = BUTTONS.index(pin) @@ -562,6 +579,9 @@ def view(self): return self.get_current_view() def update(self): + if self._poll.poll(10): + for event in self._buttons.read_edge_events(): + self.handle_button(event.line_offset) self.view.update() def render(self): @@ -695,7 +715,7 @@ def update(self, interval=5.0): def main(): - display = ST7789.ST7789( + display = st7789.ST7789( rotation=90, port=0, cs=1, diff --git a/install.sh b/install.sh index b45e7c8..61f1a4a 100755 --- a/install.sh +++ b/install.sh @@ -1,22 +1,24 @@ #!/bin/bash -LIBRARY_NAME=`grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}'` -CONFIG=/boot/config.txt -DATESTAMP=`date "+%Y-%m-%d-%H-%M-%S"` +LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') +CONFIG_FILE=config.txt +CONFIG_DIR="/boot/firmware" +DATESTAMP=$(date "+%Y-%m-%d-%H-%M-%S") CONFIG_BACKUP=false APT_HAS_UPDATED=false -RESOURCES_TOP_DIR=$HOME/Pimoroni -WD=`pwd` +RESOURCES_TOP_DIR="$HOME/Pimoroni" +VENV_BASH_SNIPPET="$RESOURCES_TOP_DIR/auto_venv.sh" +VENV_DIR="$HOME/.virtualenvs/pimoroni" USAGE="./install.sh (--unstable)" POSITIONAL_ARGS=() FORCE=false UNSTABLE=false -PYTHON="/usr/bin/python3" +PYTHON="python" +CMD_ERRORS=false user_check() { - if [ $(id -u) -eq 0 ]; then - printf "Script should not be run as root. Try './install.sh'\n" - exit 1 + if [ "$(id -u)" -eq 0 ]; then + fatal "Script should not be run as root. Try './install.sh'\n" fi } @@ -33,15 +35,6 @@ confirm() { fi } -prompt() { - read -r -p "$1 [y/N] " response < /dev/tty - if [[ $response =~ ^(yes|y|Y)$ ]]; then - true - else - false - fi -} - success() { echo -e "$(tput setaf 2)$1$(tput sgr0)" } @@ -51,51 +44,132 @@ inform() { } warning() { - echo -e "$(tput setaf 1)$1$(tput sgr0)" + echo -e "$(tput setaf 1)⚠ WARNING:$(tput sgr0) $1" +} + +fatal() { + echo -e "$(tput setaf 1)⚠ FATAL:$(tput sgr0) $1" + exit 1 +} + +find_config() { + if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then + CONFIG_DIR="/boot" + if [ ! -f "$CONFIG_DIR/$CONFIG_FILE" ]; then + fatal "Could not find $CONFIG_FILE!" + fi + fi + inform "Using $CONFIG_FILE in $CONFIG_DIR" +} + +venv_bash_snippet() { + inform "Checking for $VENV_BASH_SNIPPET\n" + if [ ! -f "$VENV_BASH_SNIPPET" ]; then + inform "Creating $VENV_BASH_SNIPPET\n" + mkdir -p "$RESOURCES_TOP_DIR" + cat << EOF > "$VENV_BASH_SNIPPET" +# Add "source $VENV_BASH_SNIPPET" to your ~/.bashrc to activate +# the Pimoroni virtual environment automagically! +VENV_DIR="$VENV_DIR" +if [ ! -f \$VENV_DIR/bin/activate ]; then + printf "Creating user Python environment in \$VENV_DIR, please wait...\n" + mkdir -p \$VENV_DIR + python3 -m venv --system-site-packages \$VENV_DIR +fi +printf " ↓ ↓ ↓ ↓ Hello, we've activated a Python venv for you. To exit, type \"deactivate\".\n" +source \$VENV_DIR/bin/activate +EOF + fi +} + +venv_check() { + PYTHON_BIN=$(which "$PYTHON") + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + if confirm "Would you like us to create and/or use a default one?"; then + printf "\n" + if [ ! -f "$VENV_DIR/bin/activate" ]; then + inform "Creating a new virtual Python environment in $VENV_DIR, please wait...\n" + mkdir -p "$VENV_DIR" + /usr/bin/python3 -m venv "$VENV_DIR" --system-site-packages + venv_bash_snippet + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + else + inform "Activating existing virtual Python environment in $VENV_DIR\n" + printf "source \"%s/bin/activate\"\n" "$VENV_DIR" + # shellcheck disable=SC1091 + source "$VENV_DIR/bin/activate" + fi + else + printf "\n" + fatal "Please create and/or activate a virtual Python environment and try again!\n" + fi + fi + printf "\n" +} + +check_for_error() { + if [ $? -ne 0 ]; then + CMD_ERRORS=true + warning "^^^ 😬 previous command did not exit cleanly!" + fi } function do_config_backup { if [ ! $CONFIG_BACKUP == true ]; then CONFIG_BACKUP=true FILENAME="config.preinstall-$LIBRARY_NAME-$DATESTAMP.txt" - inform "Backing up $CONFIG to /boot/$FILENAME\n" - sudo cp $CONFIG /boot/$FILENAME - mkdir -p $RESOURCES_TOP_DIR/config-backups/ - cp $CONFIG $RESOURCES_TOP_DIR/config-backups/$FILENAME + inform "Backing up $CONFIG_DIR/$CONFIG_FILE to $CONFIG_DIR/$FILENAME\n" + sudo cp "$CONFIG_DIR/$CONFIG_FILE" "$CONFIG_DIR/$FILENAME" + mkdir -p "$RESOURCES_TOP_DIR/config-backups/" + cp $CONFIG_DIR/$CONFIG_FILE "$RESOURCES_TOP_DIR/config-backups/$FILENAME" if [ -f "$UNINSTALLER" ]; then - echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG" >> $UNINSTALLER + echo "cp $RESOURCES_TOP_DIR/config-backups/$FILENAME $CONFIG_DIR/$CONFIG_FILE" >> "$UNINSTALLER" fi fi } function apt_pkg_install { - PACKAGES=() + PACKAGES_NEEDED=() PACKAGES_IN=("$@") + # Check the list of packages and only run update/install if we need to for ((i = 0; i < ${#PACKAGES_IN[@]}; i++)); do PACKAGE="${PACKAGES_IN[$i]}" if [ "$PACKAGE" == "" ]; then continue; fi - printf "Checking for $PACKAGE\n" - dpkg -L $PACKAGE > /dev/null 2>&1 + printf "Checking for %s\n" "$PACKAGE" + dpkg -L "$PACKAGE" > /dev/null 2>&1 if [ "$?" == "1" ]; then - PACKAGES+=("$PACKAGE") + PACKAGES_NEEDED+=("$PACKAGE") fi done - PACKAGES="${PACKAGES[@]}" + PACKAGES="${PACKAGES_NEEDED[*]}" if ! [ "$PACKAGES" == "" ]; then - echo "Installing missing packages: $PACKAGES" + printf "\n" + inform "Installing missing packages: $PACKAGES" if [ ! $APT_HAS_UPDATED ]; then sudo apt update APT_HAS_UPDATED=true fi + # shellcheck disable=SC2086 sudo apt install -y $PACKAGES + check_for_error if [ -f "$UNINSTALLER" ]; then - echo "apt uninstall -y $PACKAGES" >> $UNINSTALLER + echo "apt uninstall -y $PACKAGES" >> "$UNINSTALLER" fi fi } function pip_pkg_install { + # A null Keyring prevents pip stalling in the background PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install --upgrade "$@" + check_for_error +} + +function pip_requirements_install { + # A null Keyring prevents pip stalling in the background + PYTHON_KEYRING_BACKEND=keyring.backends.null.Keyring $PYTHON -m pip install -r "$@" + check_for_error } while [[ $# -gt 0 ]]; do @@ -116,8 +190,8 @@ while [[ $# -gt 0 ]]; do ;; *) if [[ $1 == -* ]]; then - printf "Unrecognised option: $1\n"; - printf "Usage: $USAGE\n"; + printf "Unrecognised option: %s\n" "$1"; + printf "Usage: %s\n" "$USAGE"; exit 1 fi POSITIONAL_ARGS+=("$1") @@ -125,119 +199,164 @@ while [[ $# -gt 0 ]]; do esac done +printf "Installing %s...\n\n" "$LIBRARY_NAME" + user_check +venv_check -if [ ! -f "$PYTHON" ]; then - printf "Python path $PYTHON not found!\n" - exit 1 +if [ ! -f "$(which "$PYTHON")" ]; then + fatal "Python path %s not found!\n" "$PYTHON" fi -PYTHON_VER=`$PYTHON --version` - -printf "$LIBRARY_NAME Python Library: Installer\n\n" +PYTHON_VER=$($PYTHON --version) inform "Checking Dependencies. Please wait..." +# Install toml and try to read pyproject.toml into bash variables + pip_pkg_install toml -CONFIG_VARS=`$PYTHON - < $UNINSTALLER +# Create a stub uninstaller file, we'll try to add the inverse of every +# install command run to here, though it's not complete. +cat << EOF > "$UNINSTALLER" printf "It's recommended you run these steps manually.\n" printf "If you want to run the full script, open it in\n" printf "an editor and remove 'exit 1' from below.\n" exit 1 +source $VIRTUAL_ENV/bin/activate EOF -if $UNSTABLE; then - warning "Installing unstable library from source.\n\n" -else - printf "Installing stable library from pypi.\n\n" -fi +printf "\n" inform "Installing for $PYTHON_VER...\n" + +# Install apt packages from pyproject.toml / tool.pimoroni.apt_packages apt_pkg_install "${APT_PACKAGES[@]}" + +printf "\n" + if $UNSTABLE; then + warning "Installing unstable library from source.\n" pip_pkg_install . else - pip_pkg_install $LIBRARY_NAME + inform "Installing stable library from pypi.\n" + pip_pkg_install "$LIBRARY_NAME" fi + +# shellcheck disable=SC2181 # One of two commands run, depending on --unstable flag if [ $? -eq 0 ]; then success "Done!\n" - echo "$PYTHON -m pip uninstall $LIBRARY_NAME" >> $UNINSTALLER + echo "$PYTHON -m pip uninstall $LIBRARY_NAME" >> "$UNINSTALLER" fi -cd $WD +find_config +printf "\n" + +# Run the setup commands from pyproject.toml / tool.pimoroni.commands + +inform "Running setup commands...\n" for ((i = 0; i < ${#SETUP_CMDS[@]}; i++)); do CMD="${SETUP_CMDS[$i]}" - # Attempt to catch anything that touches /boot/config.txt and trigger a backup - if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG"* ]] || [[ "$CMD" == *"\$CONFIG"* ]]; then + # Attempt to catch anything that touches config.txt and trigger a backup + if [[ "$CMD" == *"raspi-config"* ]] || [[ "$CMD" == *"$CONFIG_DIR/$CONFIG_FILE"* ]] || [[ "$CMD" == *"\$CONFIG_DIR/\$CONFIG_FILE"* ]]; then do_config_backup fi - eval $CMD + if [[ ! "$CMD" == printf* ]]; then + printf "Running: \"%s\"\n" "$CMD" + fi + eval "$CMD" + check_for_error done +printf "\n" + +# Add the config.txt entries from pyproject.toml / tool.pimoroni.configtxt + for ((i = 0; i < ${#CONFIG_TXT[@]}; i++)); do CONFIG_LINE="${CONFIG_TXT[$i]}" if ! [ "$CONFIG_LINE" == "" ]; then do_config_backup - inform "Adding $CONFIG_LINE to $CONFIG\n" - sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG - if ! grep -q "^$CONFIG_LINE" $CONFIG; then - printf "$CONFIG_LINE\n" | sudo tee --append $CONFIG + inform "Adding $CONFIG_LINE to $CONFIG_DIR/$CONFIG_FILE" + sudo sed -i "s/^#$CONFIG_LINE/$CONFIG_LINE/" $CONFIG_DIR/$CONFIG_FILE + if ! grep -q "^$CONFIG_LINE" $CONFIG_DIR/$CONFIG_FILE; then + printf "%s \n" "$CONFIG_LINE" | sudo tee --append $CONFIG_DIR/$CONFIG_FILE fi fi done +printf "\n" + +# Just a straight copy of the examples/ dir into ~/Pimoroni/board/examples + if [ -d "examples" ]; then if confirm "Would you like to copy examples to $RESOURCES_DIR?"; then inform "Copying examples to $RESOURCES_DIR" - cp -r examples/ $RESOURCES_DIR - echo "rm -r $RESOURCES_DIR" >> $UNINSTALLER + cp -r examples/ "$RESOURCES_DIR" + echo "rm -r $RESOURCES_DIR" >> "$UNINSTALLER" success "Done!" fi fi printf "\n" +if [ -f "requirements-examples.txt" ]; then + if confirm "Would you like to install example dependencies?"; then + inform "Installing dependencies from requirements-examples.txt..." + pip_requirements_install requirements-examples.txt + fi +fi + +printf "\n" + +# Use pdoc to generate basic documentation from the installed module + if confirm "Would you like to generate documentation?"; then + inform "Installing pdoc. Please wait..." pip_pkg_install pdoc - printf "Generating documentation.\n" - $PYTHON -m pdoc $LIBRARY_NAME -o $RESOURCES_DIR/docs > /dev/null - if [ $? -eq 0 ]; then + inform "Generating documentation.\n" + if $PYTHON -m pdoc "$LIBRARY_NAME" -o "$RESOURCES_DIR/docs" > /dev/null; then inform "Documentation saved to $RESOURCES_DIR/docs" success "Done!" else @@ -245,6 +364,22 @@ if confirm "Would you like to generate documentation?"; then fi fi -success "\nAll done!" -inform "If this is your first time installing you should reboot for hardware changes to take effect.\n" -inform "Find uninstall steps in $UNINSTALLER\n" +printf "\n" + +if [ "$CMD_ERRORS" = true ]; then + warning "One or more setup commands appear to have failed." + printf "This might prevent things from working properly.\n" + printf "Make sure your OS is up to date and try re-running this installer.\n" + printf "If things still don't work, report this or find help at %s.\n\n" "$GITHUB_URL" +else + success "\nAll done!" +fi + +printf "If this is your first time installing you should reboot for hardware changes to take effect.\n" +printf "Find uninstall steps in %s\n\n" "$UNINSTALLER" + +if [ "$CMD_ERRORS" = true ]; then + exit 1 +else + exit 0 +fi diff --git a/pyproject.toml b/pyproject.toml index 6318fff..181d35b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,10 +35,10 @@ classifiers = [ "Topic :: System :: Hardware", ] dependencies = [ - "pimoroni-bme280", - "ltr559", - "pimoroni-ioexpander", - "st7789", + "pimoroni-bme280 >= 1.0.0", + "ltr559 >= 1.0.0", + "pimoroni-ioexpander >= 1.0.1", + "st7789 >= 1.0.1", "smbus2" ] @@ -116,11 +116,11 @@ ignore = [ 'requirements-dev.txt' ] -[pimoroni] +[tool.pimoroni] apt_packages = [] configtxt = [] commands = [ "printf \"Setting up i2c and SPI..\n\"", - "raspi-config nonint do_spi 0", - "raspi-config nonint do_i2c 0" -] \ No newline at end of file + "sudo raspi-config nonint do_spi 0", + "sudo raspi-config nonint do_i2c 0" +] diff --git a/requirements-examples.txt b/requirements-examples.txt new file mode 100644 index 0000000..a1bd249 --- /dev/null +++ b/requirements-examples.txt @@ -0,0 +1,6 @@ +fonts +font-manrope +pyyaml +adafruit-io +numpy +pillow diff --git a/testing/RPi/GPIO/__init__.py b/testing/RPi/GPIO/__init__.py deleted file mode 100644 index c9e5593..0000000 --- a/testing/RPi/GPIO/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -BCM = 0 - -IN = 0 -OUT = 1 - -PUD_UP = 1 -PUD_DOWN = 0 - -RISING = 1 -FALLING = 0 - -handlers = {} - - -def setmode(pin): - pass - - -def setwarnings(mode): - pass - - -def setup(pin, direction, pull_up_down=None): - pass - - -def add_event_detect(pin, edge, handler, bouncetime=0): - if pin not in handlers: - handlers[pin] = {} - handlers[pin][edge] = handler diff --git a/testing/ST7789/__init__.py b/testing/st7789/__init__.py similarity index 100% rename from testing/ST7789/__init__.py rename to testing/st7789/__init__.py diff --git a/tests/conftest.py b/tests/conftest.py index 3d2d93c..9c6be8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,13 +29,22 @@ def smbus2(): del sys.modules['smbus2'] -@pytest.fixture(scope='function', autouse=False) -def gpio(): - sys.modules['RPi'] = mock.MagicMock() - sys.modules['RPi.GPIO'] = mock.MagicMock() - yield sys.modules['RPi.GPIO'] - del sys.modules['RPi.GPIO'] - del sys.modules['RPi'] +@pytest.fixture(scope="function", autouse=False) +def gpiod(): + """Mock gpiod module.""" + sys.modules["gpiod"] = mock.MagicMock() + sys.modules["gpiod.line"] = mock.MagicMock() + yield sys.modules["gpiod"] + del sys.modules["gpiod"] + + +@pytest.fixture(scope="function", autouse=False) +def gpiodevice(): + """Mock gpiodevice module.""" + sys.modules["gpiodevice"] = mock.MagicMock() + sys.modules["gpiodevice"].get_pin.return_value = (mock.Mock(), 0) + yield sys.modules["gpiodevice"] + del sys.modules["gpiodevice"] @pytest.fixture(scope='function', autouse=False) diff --git a/tests/test_setup.py b/tests/test_setup.py index 68c7928..3dd1875 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -1,17 +1,15 @@ -def test_setup(gpio, ioe, bme280, ltr559, smbus2): +def test_setup(gpiod, gpiodevice, ioe, bme280, ltr559, smbus2): import weatherhat - library = weatherhat.WeatherHAT() + _ = weatherhat.WeatherHAT() bus = smbus2.SMBus(1) bme280.BME280.assert_called_once_with(i2c_dev=bus) ltr559.LTR559.assert_called_once_with(i2c_dev=bus) - ioe.IOE.assert_called_once_with(i2c_addr=0x12, interrupt_pin=4) - - del library + ioe.IOE.assert_called_once_with(i2c_addr=0x12) -def test_api(gpio, ioe, bme280, ltr559, smbus2): +def test_api(gpiod, gpiodevice, ioe, bme280, ltr559, smbus2): import weatherhat library = weatherhat.WeatherHAT() diff --git a/tox.ini b/tox.ini index a088862..4726cef 100644 --- a/tox.ini +++ b/tox.ini @@ -20,7 +20,7 @@ commands = python -m build --no-isolation python -m twine check dist/* isort --check . - ruff --format=github . + ruff check . codespell . deps = check-manifest @@ -30,4 +30,5 @@ deps = twine build hatch - hatch-fancy-pypi-readme \ No newline at end of file + hatch-fancy-pypi-readme + diff --git a/uninstall.sh b/uninstall.sh index d5e1b5f..3314b7f 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,13 +1,22 @@ #!/bin/bash FORCE=false -LIBRARY_NAME=`grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}'` +LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME -PYTHON="/usr/bin/python3" +PYTHON="python" + + +venv_check() { + PYTHON_BIN=$(which $PYTHON) + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + exit 1 + fi +} user_check() { - if [ $(id -u) -eq 0 ]; then - printf "Script should not be run as root. Try './install.sh'\n" + if [ "$(id -u)" -eq 0 ]; then + printf "Script should not be run as root. Try './uninstall.sh'\n" exit 1 fi } @@ -46,16 +55,17 @@ warning() { echo -e "$(tput setaf 1)$1$(tput sgr0)" } -printf "$LIBRARY_NAME Python Library: Uninstaller\n\n" +printf "%s Python Library: Uninstaller\n\n" "$LIBRARY_NAME" user_check +venv_check printf "Uninstalling for Python 3...\n" -$PYTHON -m pip uninstall $LIBRARY_NAME +$PYTHON -m pip uninstall "$LIBRARY_NAME" -if [ -d $RESOURCES_DIR ]; then +if [ -d "$RESOURCES_DIR" ]; then if confirm "Would you like to delete $RESOURCES_DIR?"; then - rm -r $RESOURCES_DIR + rm -r "$RESOURCES_DIR" fi fi diff --git a/weatherhat/__init__.py b/weatherhat/__init__.py index c773cdb..89d8d6a 100644 --- a/weatherhat/__init__.py +++ b/weatherhat/__init__.py @@ -1,17 +1,19 @@ import math +import select import threading import time +import gpiod +import gpiodevice import ioexpander as io -import RPi.GPIO as GPIO from bme280 import BME280 +from gpiod.line import Bias, Edge from ltr559 import LTR559 from smbus2 import SMBus from .history import wind_degrees_to_cardinal -__version__ = '0.0.2' - +__version__ = '1.0.0' # Wind Vane PIN_WV = 8 # P0.3 ANE6 @@ -46,16 +48,25 @@ class WeatherHAT: def __init__(self): self.updated_wind_rain = False + self._interrupt_pin = 4 self._lock = threading.Lock() self._i2c_dev = SMBus(1) self._bme280 = BME280(i2c_dev=self._i2c_dev) self._ltr559 = LTR559(i2c_dev=self._i2c_dev) - self._ioe = io.IOE(i2c_addr=0x12, interrupt_pin=4) + self._ioe = io.IOE(i2c_addr=0x12) + + self._chip = gpiodevice.find_chip_by_platform() - # Fudge to enable pull-up on interrupt pin - self._ioe._gpio.setup(self._ioe._interrupt_pin, GPIO.IN, pull_up_down=GPIO.PUD_UP) + self._int = self._chip.request_lines( + consumer="weatherhat", + config={ + self._interrupt_pin: gpiod.LineSettings( + edge_detection=Edge.FALLING, bias=Bias.PULL_UP + ) + } + ) # Input voltage of IO Expander, this is 3.3 on Breakout Garden self._ioe.set_adc_vref(3.3) @@ -77,8 +88,6 @@ def __init__(self): self._ioe.set_mode(PIN_R5, io.IN_PU) self._ioe.output(PIN_R3, 0) self._ioe.set_pin_interrupt(PIN_R4, True) - self._ioe.on_interrupt(self.handle_ioe_interrupt) - self._ioe.clear_interrupt() # Data API... kinda self.temperature_offset = -7.5 @@ -101,6 +110,16 @@ def __init__(self): self.reset_counts() + self._poll_thread = threading.Thread(target=self._t_poll_ioexpander) + self._poll_thread.start() + + self._ioe.enable_interrupt_out() + self._ioe.clear_interrupt() + + def __del__(self): + self._polling = False + self._poll_thread.join() + def reset_counts(self): self._lock.acquire(blocking=True) self._ioe.clear_switch_counter(PIN_ANE2) @@ -135,7 +154,20 @@ def degrees_to_cardinal(self, degrees): value, cardinal = min(wind_degrees_to_cardinal.items(), key=lambda item: abs(item[0] - degrees)) return cardinal + def _t_poll_ioexpander(self): + self._polling = True + poll = select.poll() + poll.register(self._int.fd, select.POLLIN) + while self._polling: + if not poll.poll(10): + continue + for event in self._int.read_edge_events(): + if event.line_offset == self._interrupt_pin: + self.handle_ioe_interrupt() + time.sleep(1.0 / 100) + def update(self, interval=60.0): + # Time elapsed since last update delta = float(time.time() - self._t_start) @@ -181,7 +213,7 @@ def update(self, interval=60.0): self.rain = rain_hz * RAIN_MM_PER_TICK - def handle_ioe_interrupt(self, pin): + def handle_ioe_interrupt(self): self._lock.acquire(blocking=True) self._ioe.clear_interrupt()