diff --git a/library/.coveragerc b/.coveragerc similarity index 70% rename from library/.coveragerc rename to .coveragerc index bef4d942..ca9710de 100644 --- a/library/.coveragerc +++ b/.coveragerc @@ -3,4 +3,4 @@ source = inky omit = .tox/* relative_files = True -data_file = ../.coverage +data_file = .coverage diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..3a38301b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,42 @@ +name: Build + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Build (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + strategy: + matrix: + python: ['3.9', '3.10', '3.11'] + + env: + RELEASE_FILE: ${{ github.event.repository.name }}-${{ github.event.release.tag_name || github.sha }}-py${{ matrix.python }} + TERM: xterm-256color + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install Dependencies + run: | + make dev-deps + + - name: Build Packages + run: | + make build + + - name: Upload Packages + uses: actions/upload-artifact@v4 + with: + name: ${{ env.RELEASE_FILE }} + path: dist/ diff --git a/.github/workflows/install.yml b/.github/workflows/install.yml new file mode 100644 index 00000000..f3c1a2d6 --- /dev/null +++ b/.github/workflows/install.yml @@ -0,0 +1,40 @@ +name: Install Test + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Install (Python ${{ matrix.python }}) + runs-on: ubuntu-latest + env: + TERM: xterm-256color + strategy: + matrix: + python: ['3.9', '3.10', '3.11'] + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Stub files & Patch install.sh + run: | + mkdir -p boot/firmware + touch boot/firmware/config.txt + sed -i "s|/boot/firmware|`pwd`/boot/firmware|g" install.sh + sed -i "s|sudo raspi-config|raspi-config|g" pyproject.toml + touch raspi-config + chmod +x raspi-config + echo `pwd` >> $GITHUB_PATH + + - name: Run install.sh + run: | + ./install.sh --unstable --force diff --git a/.github/workflows/qa.yml b/.github/workflows/qa.yml new file mode 100644 index 00000000..2e166c00 --- /dev/null +++ b/.github/workflows/qa.yml @@ -0,0 +1,39 @@ +name: QA + +on: + pull_request: + push: + branches: + - main + +jobs: + test: + name: Linting & Spelling + runs-on: ubuntu-latest + env: + TERM: xterm-256color + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Python '3,11' + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install Dependencies + run: | + make dev-deps + + - name: Run Quality Assurance + run: | + make qa + + - name: Run Code Checks + run: | + make check + + - name: Run Bash Code Checks + run: | + make shellcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 786b7600..9e29cb90 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,38 +1,43 @@ -name: Python Tests +name: Tests on: pull_request: push: branches: - - master + - main jobs: test: + name: Test (Python ${{ matrix.python }}) runs-on: ubuntu-latest + env: + TERM: xterm-256color strategy: matrix: - python: [3.9] + python: ['3.9', '3.10', '3.11'] steps: - - uses: actions/checkout@v2 + - name: Checkout Code + uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} + - name: Install Dependencies run: | - python -m pip install tox + make dev-deps + - name: Run Tests - working-directory: library run: | - tox -e py + make pytest + - name: Coverage + if: ${{ matrix.python == '3.9' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - working-directory: library run: | python -m pip install coveralls coveralls --service=github - if: ${{ matrix.python == '3.9' }} - diff --git a/.stickler.yml b/.stickler.yml deleted file mode 100644 index 2466815b..00000000 --- a/.stickler.yml +++ /dev/null @@ -1,5 +0,0 @@ ---- -linters: - flake8: - python: 3 - max-line-length: 160 diff --git a/library/CHANGELOG.txt b/CHANGELOG.md similarity index 100% rename from library/CHANGELOG.txt rename to CHANGELOG.md diff --git a/library/MANIFEST.in b/MANIFEST.in similarity index 100% rename from library/MANIFEST.in rename to MANIFEST.in diff --git a/Makefile b/Makefile index 8f176715..56cf0dfe 100644 --- a/Makefile +++ b/Makefile @@ -1,61 +1,66 @@ -LIBRARY_VERSION=`cat library/setup.py | grep version | awk -F"'" '{print $$2}'` -LIBRARY_NAME=`cat library/setup.py | grep name | awk -F"'" '{print $$2}'` +LIBRARY_NAME := $(shell hatch project metadata name 2> /dev/null) +LIBRARY_VERSION := $(shell hatch version 2> /dev/null) -.PHONY: usage install uninstall +.PHONY: usage install uninstall check pytest qa build-deps check tag wheel sdist clean dist testdeploy deploy usage: +ifdef LIBRARY_NAME + @echo "Library: ${LIBRARY_NAME}" + @echo "Version: ${LIBRARY_VERSION}\n" +else + @echo "WARNING: You should 'make dev-deps'\n" +endif @echo "Usage: make , where target is one of:\n" - @echo "install: install the library locally from source" - @echo "uninstall: uninstall the local library" - @echo "check: peform basic integrity checks on the codebase" - @echo "python-readme: generate library/README.rst from README.md" - @echo "python-wheels: build python .whl files for distribution" - @echo "python-sdist: build python source distribution" - @echo "python-clean: clean python build and dist directories" - @echo "python-dist: build all python distribution files" - @echo "python-testdeploy: build all and deploy to test PyPi" + @echo "install: install the library locally from source" + @echo "uninstall: uninstall the local library" + @echo "dev-deps: install Python dev dependencies" + @echo "check: perform basic integrity checks on the codebase" + @echo "qa: run linting and package QA" + @echo "pytest: run Python test fixtures" + @echo "clean: clean Python build and dist directories" + @echo "build: build Python distribution files" + @echo "testdeploy: build and upload to test PyPi" + @echo "deploy: build and upload to PyPi" + @echo "tag: tag the repository with the current version\n" + +version: + @hatch version install: - ./install.sh + ./install.sh --unstable uninstall: ./uninstall.sh -check: - @echo "Checking for trailing whitespace" - @! grep -IlUrn --color "[[:blank:]]$$" --exclude-dir=sphinx --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO - @echo "Checking for DOS line-endings" - @! grep -IlUrn --color " " --exclude-dir=sphinx --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile - @echo "Checking library/CHANGELOG.txt" - @cat library/CHANGELOG.txt | grep ^${LIBRARY_VERSION} - @echo "Checking library/${LIBRARY_NAME}/__init__.py" - @cat library/${LIBRARY_NAME}/__init__.py | grep "^__version__ = '${LIBRARY_VERSION}'" +dev-deps: + python3 -m pip install -r requirements-dev.txt + sudo apt install dos2unix shellcheck -python-readme: library/README.md +check: + @bash check.sh -python-license: library/LICENSE.txt +shellcheck: + shellcheck *.sh -library/README.md: README.md - cp README.md library/README.md +qa: + tox -e qa -library/LICENSE.txt: LICENSE - cp LICENSE library/LICENSE.txt +pytest: + tox -e py -python-wheels: python-readme python-license - cd library; python3 setup.py bdist_wheel +nopost: + @bash check.sh --nopost -python-sdist: python-readme python-license - cd library; python3 setup.py sdist +tag: version + git tag -a "v${LIBRARY_VERSION}" -m "Version ${LIBRARY_VERSION}" -python-clean: - -rm -r library/dist - -rm -r library/build - -rm -r library/*.egg-info +build: check + @hatch build -python-dist: python-clean python-wheels python-sdist - ls library/dist +clean: + -rm -r dist -python-testdeploy: python-dist - twine upload --repository-url https://test.pypi.org/legacy/ library/dist/* +testdeploy: build + twine upload --repository testpypi dist/* -python-deploy: check python-dist - twine upload library/dist/* +deploy: nopost build + twine upload dist/* diff --git a/README.md b/README.md index 2cf1f74b..1652b9e0 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Inky -[![Build Status](https://travis-ci.com/pimoroni/inky.svg?branch=master)](https://travis-ci.com/pimoroni/inky) -[![Coverage Status](https://coveralls.io/repos/github/pimoroni/inky/badge.svg?branch=master)](https://coveralls.io/github/pimoroni/inky?branch=master) +[![Build Status](https://img.shields.io/github/actions/workflow/status/pimoroni/inky/test.yml?branch=main)](https://github.com/pimoroni/inky/actions/workflows/test.yml) +[![Coverage Status](https://coveralls.io/repos/github/pimoroni/inky/badge.svg?branch=main)](https://coveralls.io/github/pimoroni/inky?branch=main) [![PyPi Package](https://img.shields.io/pypi/v/inky.svg)](https://pypi.python.org/pypi/inky) [![Python Versions](https://img.shields.io/pypi/pyversions/inky.svg)](https://pypi.python.org/pypi/inky) @@ -22,23 +22,55 @@ Python library for [Inky pHAT](https://shop.pimoroni.com/products/inky-phat), [I # Installation -First, make sure you have I2C and SPI enabled in `sudo raspi-config`. +# Installing -The Python pip package is named inky, on the Raspberry Pi install with: +We'd recommend using this library with Raspberry Pi OS Bookworm or later. It requires Python ≥3.7. + +## Full install (recommended): + +We've created an easy installation script that will install all pre-requisites and get you 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/inky +cd inky +./install.sh +``` + +**Note** Libraries will be installed in the "pimoroni" virtual environment, you will need to activate it to run examples: ``` -pip3 install inky[rpi,example-depends] +source ~/.virtualenvs/pimoroni/bin/activate ``` -This will install Inky along with dependencies for the Raspberry Pi, plus fonts used by the examples. +## Development: -If you want to simulate Inky on your desktop, use: +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/inky +cd inky +./install.sh --unstable ``` -pip3 install inky -``` -You may need to use `sudo pip3` or `sudo pip` depending on your environment and Python version. +## 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 inky` + +In some cases you may need to us `sudo` or install pip with: `sudo apt install python3-pip`. + +This will not make any configuration changes, so you may also need to enable: + +* i2c: `sudo raspi-config nonint do_i2c 0` +* spi: `sudo raspi-config nonint do_spi 0` + +You can optionally run `sudo raspi-config` or the graphical Raspberry Pi Configuration UI to enable interfaces. # Usage diff --git a/check.sh b/check.sh new file mode 100755 index 00000000..38dfc3a1 --- /dev/null +++ b/check.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# This script handles some basic QA checks on the source + +NOPOST=$1 +LIBRARY_NAME=$(hatch project metadata name) +LIBRARY_VERSION=$(hatch version | awk -F "." '{print $1"."$2"."$3}') +POST_VERSION=$(hatch version | awk -F "." '{print substr($4,0,length($4))}') +TERM=${TERM:="xterm-256color"} + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} + +while [[ $# -gt 0 ]]; do + K="$1" + case $K in + -p|--nopost) + NOPOST=true + shift + ;; + *) + if [[ $1 == -* ]]; then + printf "Unrecognised option: %s\n" "$1"; + exit 1 + fi + POSITIONAL_ARGS+=("$1") + shift + esac +done + +inform "Checking $LIBRARY_NAME $LIBRARY_VERSION\n" + +inform "Checking for trailing whitespace..." +if grep -IUrn --color "[[:blank:]]$" --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=PKG-INFO; then + warning "Trailing whitespace found!" + exit 1 +else + success "No trailing whitespace found." +fi +printf "\n" + +inform "Checking for DOS line-endings..." +if grep -lIUrn --color $'\r' --exclude-dir=dist --exclude-dir=.tox --exclude-dir=.git --exclude=Makefile; then + warning "DOS line-endings found!" + exit 1 +else + success "No DOS line-endings found." +fi +printf "\n" + +inform "Checking CHANGELOG.md..." +if ! grep "^${LIBRARY_VERSION}" CHANGELOG.md > /dev/null 2>&1; then + warning "Changes missing for version ${LIBRARY_VERSION}! Please update CHANGELOG.md." + exit 1 +else + success "Changes found for version ${LIBRARY_VERSION}." +fi +printf "\n" + +inform "Checking for git tag ${LIBRARY_VERSION}..." +if ! git tag -l | grep -E "${LIBRARY_VERSION}$"; then + warning "Missing git tag for version ${LIBRARY_VERSION}" +fi +printf "\n" + +if [[ $NOPOST ]]; then + inform "Checking for .postN on library version..." + if [[ "$POST_VERSION" != "" ]]; then + warning "Found .$POST_VERSION on library version." + inform "Please only use these for testpypi releases." + exit 1 + else + success "OK" + fi +fi diff --git a/examples/7color/advanced/dither.py b/examples/7color/advanced/dither.py index a4639707..6c554420 100755 --- a/examples/7color/advanced/dither.py +++ b/examples/7color/advanced/dither.py @@ -3,9 +3,10 @@ import sys import hitherdither -from inky import auto from PIL import Image +from inky import auto + print("""dither.py Advanced dithering example using Hitherdither by Henrik Blidh: @@ -42,7 +43,7 @@ if len(sys.argv) > 2: saturation = float(sys.argv[2]) -palette = hitherdither.palette.Palette(inky._palette_blend(saturation, dtype='uint24')) +palette = hitherdither.palette.Palette(inky._palette_blend(saturation, dtype="uint24")) image = Image.open(sys.argv[1]).convert("RGB") image_resized = image.resize(inky.resolution) diff --git a/examples/7color/buttons.py b/examples/7color/buttons.py index 3f3597a1..c699b4d9 100755 --- a/examples/7color/buttons.py +++ b/examples/7color/buttons.py @@ -1,45 +1,53 @@ #!/usr/bin/env python3 -import signal -import RPi.GPIO as GPIO +import gpiod +import gpiodevice +from gpiod.line import Bias, Direction, 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! """) -# Gpio pins for each button (from top to bottom) -BUTTONS = [5, 6, 16, 24] +# GPIO pins for each button (from top to bottom) +# These will vary depending on platform and the ones +# below should be correct for Raspberry Pi 5. +# Run "gpioinfo" to find out what yours might be +BUTTONS = ["PIN29", "PIN31", "PIN36", "PIN18"] # These correspond to buttons A, B, C and D respectively -LABELS = ['A', 'B', 'C', 'D'] +LABELS = ["A", "B", "C", "D"] -# Set up RPi.GPIO with the "BCM" numbering scheme -GPIO.setmode(GPIO.BCM) +# Create settings for all the input pins, we want them to be inputs +# with a pull-up and a falling edge detection. +INPUT = gpiod.LineSettings(direction=Direction.INPUT, bias=Bias.PULL_UP, edge_detection=Edge.FALLING) -# 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) +# Find the gpiochip device we need, we'll use +# gpiodevice for this, since it knows the right device +# for its supported platforms. +chip = gpiodevice.find_chip_by_platform() +# Build our config for each pin/line we want to use +OFFSETS = [chip.line_offset_from_id(id) for id in BUTTONS] +line_config = dict.fromkeys(OFFSETS, INPUT) -# "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)] - print("Button press detected on pin: {} label: {}".format(pin, label)) +# Request the lines, *whew* +request = chip.request_lines(consumer="inky7-buttons", config=line_config) +# "handle_button" will be called every time a button is pressed +# It receives one argument: the associated gpiod event object. +def handle_button(event): + index = OFFSETS.index(event.line_offset) + pin = BUTTONS[index] + label = LABELS[index] + print(f"Button press detected on pin: {pin} label: {label}") -# 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 250ms to smooth out button presses. -for pin in BUTTONS: - GPIO.add_event_detect(pin, GPIO.FALLING, handle_button, bouncetime=250) -# Finally, since button handlers don't require a "while True" loop, -# we pause the script to prevent it exiting immediately. -signal.pause() +while True: + for event in request.read_edge_events(): + handle_button(event) diff --git a/examples/7color/colour-palette.py b/examples/7color/colour-palette.py index 5e8d8104..6bac6998 100755 --- a/examples/7color/colour-palette.py +++ b/examples/7color/colour-palette.py @@ -1,22 +1,23 @@ #!/usr/bin/env python3 -from inky.inky_uc8159 import Inky import argparse import pathlib import struct import sys +from inky.inky_uc8159 import Inky + parser = argparse.ArgumentParser() -parser.add_argument('--type', '-t', choices=['css', 'act', 'raw', 'pal', 'gpl'], help='Type of palette to output') -parser.add_argument('--saturation', '-s', type=float, default=0.5, help='Colour palette saturation') -parser.add_argument('--file', '-f', type=pathlib.Path, help='Output file') +parser.add_argument("--type", "-t", choices=["css", "act", "raw", "pal", "gpl"], help="Type of palette to output") +parser.add_argument("--saturation", "-s", type=float, default=0.5, help="Colour palette saturation") +parser.add_argument("--file", "-f", type=pathlib.Path, help="Output file") args = parser.parse_args() inky = Inky() -names = ['black', 'white', 'green', 'blue', 'red', 'yellow', 'orange'] +names = ["black", "white", "green", "blue", "red", "yellow", "orange"] if args.file is None: print("You must specify an output palette file.") @@ -25,13 +26,13 @@ def raw_palette(): palette = bytearray(768) - palette[0:8 * 3] = inky._palette_blend(args.saturation, dtype='uint8') + palette[0 : 8 * 3] = inky._palette_blend(args.saturation, dtype="uint8") return palette -if args.type == 'css': - palette = inky._palette_blend(args.saturation, dtype='uint24') - with open(args.file, 'w+') as f: +if args.type == "css": + palette = inky._palette_blend(args.saturation, dtype="uint24") + with open(args.file, "w+") as f: for i in range(7): name = names[i] colour = palette[i] @@ -39,27 +40,27 @@ def raw_palette(): .{name}_bg {{background-color:#{colour:06x}}} """.format(name=name, colour=colour)) -if args.type == 'gpl': - palette = inky._palette_blend(args.saturation, dtype='uint24') - with open(args.file, 'w+') as f: +if args.type == "gpl": + palette = inky._palette_blend(args.saturation, dtype="uint24") + with open(args.file, "w+") as f: f.write("GIMP Palette\n") f.write("Name: InkyImpressions\n") f.write("Columns: 7\n") for i in range(7): name = names[i] colour = palette[i] - r = (colour & 0xff0000) >> 16 - g = (colour & 0x00ff00) >> 8 - b = (colour & 0x0000ff) + r = (colour & 0xFF0000) >> 16 + g = (colour & 0x00FF00) >> 8 + b = (colour & 0x0000FF) f.write("{r} {g} {b} Index {i} # {name}\n".format(r=r, g=g, b=b, i=i, name=name)) -if args.type in ('pal', 'raw'): +if args.type in ("pal", "raw"): palette = raw_palette() - with open(args.file, 'wb+') as f: + with open(args.file, "wb+") as f: f.write(palette) -if args.type == 'act': +if args.type == "act": palette = raw_palette() palette += struct.pack(">HH", 7, 0xFFFF) - with open(args.file, 'wb+') as f: + with open(args.file, "wb+") as f: f.write(palette) diff --git a/examples/7color/cycle.py b/examples/7color/cycle.py index 318ee01a..02d7cfb0 100755 --- a/examples/7color/cycle.py +++ b/examples/7color/cycle.py @@ -5,7 +5,7 @@ inky = auto(ask_user=True, verbose=True) -colors = ['Black', 'White', 'Green', 'Blue', 'Red', 'Yellow', 'Orange'] +colors = ["Black", "White", "Green", "Blue", "Red", "Yellow", "Orange"] for color in range(7): print("Color: {}".format(colors[color])) diff --git a/examples/7color/graph.py b/examples/7color/graph.py index 9fdf0a93..b97fe515 100755 --- a/examples/7color/graph.py +++ b/examples/7color/graph.py @@ -1,12 +1,13 @@ #!/usr/bin/env python3 +import argparse import io +import seaborn +from matplotlib import pyplot from PIL import Image + from inky.auto import auto -from matplotlib import pyplot -import seaborn -import argparse print(""" diff --git a/examples/7color/image.py b/examples/7color/image.py index 62fcc956..3d2ab1cc 100755 --- a/examples/7color/image.py +++ b/examples/7color/image.py @@ -1,25 +1,35 @@ #!/usr/bin/env python3 +import argparse +import pathlib import sys from PIL import Image from inky.auto import auto +parser = argparse.ArgumentParser() + +parser.add_argument("--saturation", "-s", type=float, default=0.5, help="Colour palette saturation") +parser.add_argument("--file", "-f", type=pathlib.Path, help="Image file") + inky = auto(ask_user=True, verbose=True) -saturation = 0.5 -if len(sys.argv) == 1: - print(""" -Usage: {file} image-file -""".format(file=sys.argv[0])) +args, _ = parser.parse_known_args() + +saturation = args.saturation + +if not args.file: + print(f"""Usage: + {sys.argv[0]} --file image.png (--saturation 0.5)""") sys.exit(1) -image = Image.open(sys.argv[1]) +image = Image.open(args.file) resizedimage = image.resize(inky.resolution) -if len(sys.argv) > 2: - saturation = float(sys.argv[2]) +try: + inky.set_image(resizedimage, saturation=saturation) +except TypeError: + inky.set_image(resizedimage) -inky.set_image(resizedimage, saturation=saturation) inky.show() diff --git a/examples/clean.py b/examples/clean.py index fe72680a..9e46a7e5 100755 --- a/examples/clean.py +++ b/examples/clean.py @@ -4,9 +4,10 @@ import argparse import time -from inky.auto import auto from PIL import Image +from inky.auto import auto + print("""Inky pHAT: Clean Displays solid blocks of red, black, and white to clean the Inky pHAT @@ -18,7 +19,7 @@ # Command line arguments to determine number of cycles to run parser = argparse.ArgumentParser() -parser.add_argument('--number', '-n', type=int, required=False, help="number of cycles") +parser.add_argument("--number", "-n", type=int, required=False, help="number of cycles") args, _ = parser.parse_known_args() # The number of red / black / white refreshes to run diff --git a/examples/logo.py b/examples/logo.py index 0a2cc14e..530ca721 100755 --- a/examples/logo.py +++ b/examples/logo.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 import os + from PIL import Image -from inky.auto import auto +from inky.auto import auto print("""Inky pHAT/wHAT: Logo @@ -29,24 +30,24 @@ if inky_display.resolution in ((212, 104), (250, 122)): if inky_display.resolution == (250, 122): - if inky_display.colour == 'black': + if inky_display.colour == "black": img = Image.open(os.path.join(PATH, "phat/resources/InkypHAT-250x122-bw.png")) else: img = Image.open(os.path.join(PATH, "phat/resources/InkypHAT-250x122.png")) else: - if inky_display.colour == 'black': + if inky_display.colour == "black": img = Image.open(os.path.join(PATH, "phat/resources/InkypHAT-212x104-bw.png")) else: img = Image.open(os.path.join(PATH, "phat/resources/InkypHAT-212x104.png")) -elif inky_display.resolution in ((400, 300), ): - if inky_display.colour == 'black': +elif inky_display.resolution in ((400, 300),): + if inky_display.colour == "black": img = Image.open(os.path.join(PATH, "what/resources/InkywHAT-400x300-bw.png")) else: img = Image.open(os.path.join(PATH, "what/resources/InkywHAT-400x300.png")) -elif inky_display.resolution in ((600, 448), ): +elif inky_display.resolution in ((600, 448),): img = Image.open(os.path.join(PATH, "what/resources/InkywHAT-400x300.png")) img = img.resize(inky_display.resolution) diff --git a/examples/name-badge.py b/examples/name-badge.py index 3fbd2820..ade53549 100755 --- a/examples/name-badge.py +++ b/examples/name-badge.py @@ -2,14 +2,11 @@ import argparse -from PIL import Image, ImageFont, ImageDraw from font_hanken_grotesk import HankenGroteskBold, HankenGroteskMedium from font_intuitive import Intuitive -from inky.auto import auto +from PIL import Image, ImageDraw, ImageFont -def getsize(font, text): - _, _, right, bottom = font.getbbox(text) - return (right, bottom) +from inky.auto import auto print("""Inky pHAT/wHAT: Hello... my name is: @@ -17,13 +14,17 @@ def getsize(font, text): """) +def getsize(font, text): + _, _, right, bottom = font.getbbox(text) + return (right, bottom) + try: inky_display = auto(ask_user=True, verbose=True) except TypeError: raise TypeError("You need to update the Inky library to >= v1.1.0") parser = argparse.ArgumentParser() -parser.add_argument('--name', '-n', type=str, required=True, help="Your name") +parser.add_argument("--name", "-n", type=str, required=True, help="Your name") args, _ = parser.parse_known_args() # inky_display.set_rotation(180) diff --git a/examples/phat/calendar-phat.py b/examples/phat/calendar-phat.py index 99a2e37f..4ca9fefa 100755 --- a/examples/phat/calendar-phat.py +++ b/examples/phat/calendar-phat.py @@ -5,9 +5,10 @@ import datetime import os -from inky.auto import auto from PIL import Image, ImageDraw +from inky.auto import auto + print("""Inky pHAT: Calendar Draws a calendar for the current month to your Inky pHAT. @@ -159,7 +160,7 @@ def print_number(position, number, colour): crop_x = 2 + (16 * x) # Crop the relevant day name from our text image - crop_region = ((crop_x, 0, crop_x + 16, 9)) + crop_region = (crop_x, 0, crop_x + 16, 9) day_mask = text_mask.crop(crop_region) img.paste(inky_display.WHITE, (o_x + 4, cal_y + 2), day_mask) diff --git a/examples/phat/weather-phat.py b/examples/phat/weather-phat.py index 751527ee..f50c23c4 100755 --- a/examples/phat/weather-phat.py +++ b/examples/phat/weather-phat.py @@ -2,15 +2,16 @@ # -*- coding: utf-8 -*- import glob +import json import os import time -import json from sys import exit from font_fredoka_one import FredokaOne -from inky.auto import auto from PIL import Image, ImageDraw, ImageFont +from inky.auto import auto + """ To run this example on Python 2.x you should: sudo apt install python-lxml diff --git a/examples/tests/border.py b/examples/tests/border.py index 2f57441d..c9889cb3 100755 --- a/examples/tests/border.py +++ b/examples/tests/border.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 -import time import sys +import time + from PIL import Image + from inky import InkyPHAT INKY_COLOUR = None @@ -9,14 +11,14 @@ if len(sys.argv) > 1: INKY_COLOUR = sys.argv[1] -if INKY_COLOUR not in ['red', 'yellow', 'black']: +if INKY_COLOUR not in ["red", "yellow", "black"]: print("Usage: {} ".format(sys.argv[0])) sys.exit(1) phat = InkyPHAT(INKY_COLOUR) -white = Image.new('P', (212, 104), phat.WHITE) -black = Image.new('P', (212, 104), phat.BLACK) +white = Image.new("P", (212, 104), phat.WHITE) +black = Image.new("P", (212, 104), phat.BLACK) while True: print("White") @@ -25,14 +27,14 @@ phat.show() time.sleep(1) - if INKY_COLOUR == 'red': + if INKY_COLOUR == "red": print("Red") phat.set_border(phat.RED) phat.set_image(white) phat.show() time.sleep(1) - if INKY_COLOUR == 'yellow': + if INKY_COLOUR == "yellow": print("Yellow") phat.set_border(phat.YELLOW) phat.set_image(white) @@ -45,14 +47,14 @@ phat.show() time.sleep(1) - if INKY_COLOUR == 'red': + if INKY_COLOUR == "red": print("Red") phat.set_border(phat.RED) phat.set_image(white) phat.show() time.sleep(1) - if INKY_COLOUR == 'yellow': + if INKY_COLOUR == "yellow": print("Yellow") phat.set_border(phat.YELLOW) phat.set_image(white) diff --git a/examples/what/dither-image-what.py b/examples/what/dither-image-what.py index d183ada8..7cc4f6d7 100755 --- a/examples/what/dither-image-what.py +++ b/examples/what/dither-image-what.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import argparse + from PIL import Image + from inky.auto import auto print("""Inky wHAT: Dither image @@ -17,7 +19,7 @@ # Grab the image argument from the command line parser = argparse.ArgumentParser() -parser.add_argument('--image', '-i', type=str, required=True, help="Input image to be converted/displayed") +parser.add_argument("--image", "-i", type=str, required=True, help="Input image to be converted/displayed") args, _ = parser.parse_known_args() img_file = args.image diff --git a/examples/what/quotes-what.py b/examples/what/quotes-what.py index 6670d7d2..8aa0a7e9 100755 --- a/examples/what/quotes-what.py +++ b/examples/what/quotes-what.py @@ -4,11 +4,11 @@ import random import sys -from inky.auto import auto - -from PIL import Image, ImageFont, ImageDraw -from font_source_serif_pro import SourceSerifProSemibold from font_source_sans_pro import SourceSansProSemibold +from font_source_serif_pro import SourceSerifProSemibold +from PIL import Image, ImageDraw, ImageFont + +from inky.auto import auto print("""Inky wHAT: Quotes @@ -96,7 +96,7 @@ def reflow_quote(quote, width, font): "Niels Bohr", "Nikola Tesla", "Rosalind Franklin", - "Stephen Hawking" + "Stephen Hawking", ] # The amount of padding around the quote. Note that diff --git a/library/inky/__init__.py b/inky/__init__.py similarity index 59% rename from library/inky/__init__.py rename to inky/__init__.py index 240a28f5..db6b0f86 100644 --- a/library/inky/__init__.py +++ b/inky/__init__.py @@ -1,20 +1,22 @@ """Inky e-Ink Display Drivers.""" -from . import inky # noqa: F401 -from .inky import BLACK, WHITE, RED, YELLOW # noqa: F401 -from .phat import InkyPHAT, InkyPHAT_SSD1608 # noqa: F401 -from .what import InkyWHAT # noqa: F401 -from .mock import InkyMockPHAT, InkyMockWHAT # noqa: F401 -from .inky_uc8159 import Inky as Inky7Colour # noqa: F401 -from .inky_ssd1683 import Inky as InkyWHAT_SSD1683 # noqa: F401 +from . import inky # noqa: F401 +from .auto import auto # noqa: F401 +from .inky import BLACK, RED, WHITE, YELLOW # noqa: F401 from .inky_ac073tc1a import Inky as Inky_Impressions_7 # noqa: F401 -from .auto import auto # noqa: F401 +from .inky_ssd1683 import Inky as InkyWHAT_SSD1683 # noqa: F401 +from .inky_uc8159 import Inky as Inky7Colour # noqa: F401 +from .mock import InkyMockPHAT, InkyMockWHAT # noqa: F401 +from .phat import InkyPHAT, InkyPHAT_SSD1608 # noqa: F401 +from .what import InkyWHAT # noqa: F401 -__version__ = '1.5.0' +__version__ = "1.5.0" try: from pkg_resources import declare_namespace + declare_namespace(__name__) except ImportError: from pkgutil import extend_path + __path__ = extend_path(__path__, __name__) diff --git a/library/inky/auto.py b/inky/auto.py similarity index 90% rename from library/inky/auto.py rename to inky/auto.py index 3b05f9a3..f37ee459 100644 --- a/library/inky/auto.py +++ b/inky/auto.py @@ -1,12 +1,12 @@ """Automatic Inky setup from i2c EEPROM.""" -from .phat import InkyPHAT, InkyPHAT_SSD1608 # noqa: F401 -from .what import InkyWHAT # noqa: F401 -from .inky_uc8159 import Inky as InkyUC8159 # noqa: F401 -from .inky_ssd1683 import Inky as InkyWHAT_SSD1683 # noqa: F401 -from .inky_ac073tc1a import Inky as InkyAC073TC1A # noqa: F401 -from . import eeprom import argparse +from . import eeprom +from .inky_ac073tc1a import Inky as InkyAC073TC1A # noqa: F401 +from .inky_ssd1683 import Inky as InkyWHAT_SSD1683 # noqa: F401 +from .inky_uc8159 import Inky as InkyUC8159 # noqa: F401 +from .phat import InkyPHAT, InkyPHAT_SSD1608 # noqa: F401 +from .what import InkyWHAT # noqa: F401 DISPLAY_TYPES = ["what", "phat", "phatssd1608", "impressions", "7colour", "whatssd1683", "impressions73"] DISPLAY_COLORS = ["red", "black", "yellow"] @@ -39,9 +39,9 @@ def auto(i2c_bus=None, ask_user=False, verbose=False): if verbose: print("Failed to detect an Inky board. Trying --type/--colour arguments instead...\n") parser = argparse.ArgumentParser() - parser.add_argument('--simulate', '-s', action='store_true', default=False, help="Simulate Inky display") - parser.add_argument('--type', '-t', type=str, required=True, choices=DISPLAY_TYPES, help="Type of display") - parser.add_argument('--colour', '-c', type=str, required=False, choices=DISPLAY_COLORS, help="Display colour") + parser.add_argument("--simulate", "-s", action="store_true", default=False, help="Simulate Inky display") + parser.add_argument("--type", "-t", type=str, required=True, choices=DISPLAY_TYPES, help="Type of display") + parser.add_argument("--colour", "-c", type=str, required=False, choices=DISPLAY_COLORS, help="Display colour") args, _ = parser.parse_known_args() if args.simulate: cls = None diff --git a/library/inky/eeprom.py b/inky/eeprom.py similarity index 74% rename from library/inky/eeprom.py rename to inky/eeprom.py index ddb2984a..65a11446 100644 --- a/library/inky/eeprom.py +++ b/inky/eeprom.py @@ -5,47 +5,46 @@ import datetime import struct - EEP_ADDRESS = 0x50 EEP_WP = 12 DISPLAY_VARIANT = [ None, - 'Red pHAT (High-Temp)', - 'Yellow wHAT', - 'Black wHAT', - 'Black pHAT', - 'Yellow pHAT', - 'Red wHAT', - 'Red wHAT (High-Temp)', - 'Red wHAT', + "Red pHAT (High-Temp)", + "Yellow wHAT", + "Black wHAT", + "Black pHAT", + "Yellow pHAT", + "Red wHAT", + "Red wHAT (High-Temp)", + "Red wHAT", None, - 'Black pHAT (SSD1608)', - 'Red pHAT (SSD1608)', - 'Yellow pHAT (SSD1608)', + "Black pHAT (SSD1608)", + "Red pHAT (SSD1608)", + "Yellow pHAT (SSD1608)", None, - '7-Colour (UC8159)', - '7-Colour 640x400 (UC8159)', - '7-Colour 640x400 (UC8159)', - 'Black wHAT (SSD1683)', - 'Red wHAT (SSD1683)', - 'Yellow wHAT (SSD1683)', - '7-Colour 800x480 (AC073TC1A)' + "7-Colour (UC8159)", + "7-Colour 640x400 (UC8159)", + "7-Colour 640x400 (UC8159)", + "Black wHAT (SSD1683)", + "Red wHAT (SSD1683)", + "Yellow wHAT (SSD1683)", + "7-Colour 800x480 (AC073TC1A)", ] class EPDType: """Class to represent EPD EEPROM structure.""" - valid_colors = [None, 'black', 'red', 'yellow', None, '7colour'] + valid_colors = [None, "black", "red", "yellow", None, "7colour"] def __init__(self, width, height, color, pcb_variant, display_variant, write_time=None): """Initialise new EEPROM data structure.""" self.width = width self.height = height self.color = color - if type(color) == str: + if isinstance(color, str): self.set_color(color) self.pcb_variant = pcb_variant self.display_variant = display_variant @@ -68,7 +67,7 @@ def __repr__(self): def from_bytes(class_object, data): """Initialise new EEPROM data structure from a bytes-like object or list.""" data = bytearray(data) - data = struct.unpack('= timeout: - warnings.warn("Busy Wait: Timed out after {:0.2f}s".format(time.time() - t_start)) - return + event = self._gpio.wait_edge_events(timedelta(seconds=timeout)) + if not event: + warnings.warn(f"Busy Wait: Timed out after {timeout:0.2f}s") + return - # print("Busy_waited", time.time()-t_start, "out of", timeout, "seconds") + for event in self._gpio.read_edge_events(): + print(timeout, event) def _update(self, buf): """Update display. @@ -300,11 +309,11 @@ def _update(self, buf): # TODO there has to be a better way to force the white colour to be used instead of clear... for i in range(len(buf)): - if buf[i] & 0xf == 7: - buf[i] = (buf[i] & 0xf0) + 1 + if buf[i] & 0xF == 7: + buf[i] = (buf[i] & 0xF0) + 1 # print buf[i] - if buf[i] & 0xf0 == 0x70: - buf[i] = (buf[i] & 0xf) + 0x10 + if buf[i] & 0xF0 == 0x70: + buf[i] = (buf[i] & 0xF) + 0x10 # print buf[i] self._send_command(AC073TC1_DTM, buf) @@ -349,7 +358,7 @@ def show(self, busy_wait=True): buf = ((buf[::2] << 4) & 0xF0) | (buf[1::2] & 0x0F) - self._update(buf.astype('uint8').tolist()) + self._update(buf.astype("uint8").tolist()) def set_border(self, colour): """Set the border colour.""" @@ -366,8 +375,6 @@ def set_image(self, image, saturation=0.5): if not image.size == (self.width, self.height): raise ValueError("Image must be ({}x{}) pixels!".format(self.width, self.height)) if not image.mode == "P": - if Image is None: - raise RuntimeError("PIL is required for converting images: sudo apt install python-pil python3-pil") palette = self._palette_blend(saturation) # Image size doesn't matter since it's just the palette we're using palette_image = Image.new("P", (1, 1)) @@ -385,16 +392,16 @@ def _spi_write(self, dc, values): :param values: list of values to write """ - self._gpio.output(self.cs_pin, 0) - self._gpio.output(self.dc_pin, dc) + self._gpio.set_value(self.cs_pin, Value.INACTIVE) + self._gpio.set_value(self.dc_pin, Value.ACTIVE if dc else Value.INACTIVE) - if type(values) is str: + if isinstance(values, str): values = [ord(c) for c in values] for byte_value in values: self._spi_bus.xfer([byte_value]) - self._gpio.output(self.cs_pin, 1) + self._gpio.set_value(self.cs_pin, Value.ACTIVE) def _send_command(self, command, data=None): """Send command over SPI. diff --git a/library/inky/inky_ssd1608.py b/inky/inky_ssd1608.py similarity index 68% rename from library/inky/inky_ssd1608.py rename to inky/inky_ssd1608.py index db553541..a5474c01 100644 --- a/library/inky/inky_ssd1608.py +++ b/inky/inky_ssd1608.py @@ -1,25 +1,27 @@ """Inky e-Ink Display Driver.""" import time +import warnings +from datetime import timedelta +import gpiod +import gpiodevice +import numpy +from gpiod.line import Bias, Direction, Edge, Value from PIL import Image -from . import eeprom, ssd1608 -try: - import numpy -except ImportError: - raise ImportError('This library requires the numpy module\nInstall with: sudo apt install python-numpy') +from . import eeprom, ssd1608 WHITE = 0 BLACK = 1 RED = YELLOW = 2 -RESET_PIN = 27 -BUSY_PIN = 17 -DC_PIN = 22 +RESET_PIN = 27 # PIN13 +BUSY_PIN = 17 # PIN11 +DC_PIN = 22 # PIN15 MOSI_PIN = 10 SCLK_PIN = 11 -CS0_PIN = 0 +CS0_PIN = 8 _SPI_CHUNK_SIZE = 4096 _SPI_COMMAND = 0 @@ -38,7 +40,7 @@ class Inky: RED = 2 YELLOW = 2 - def __init__(self, resolution=(250, 122), colour='black', cs_pin=CS0_PIN, dc_pin=DC_PIN, reset_pin=RESET_PIN, busy_pin=BUSY_PIN, h_flip=False, v_flip=False, spi_bus=None, i2c_bus=None, gpio=None): # noqa: E501 + def __init__(self, resolution=(250, 122), colour="black", cs_pin=CS0_PIN, dc_pin=DC_PIN, reset_pin=RESET_PIN, busy_pin=BUSY_PIN, h_flip=False, v_flip=False, spi_bus=None, i2c_bus=None, gpio=None): # noqa: E501 """Initialise an Inky Display. :param resolution: (width, height) in pixels, default: (400, 300) @@ -55,14 +57,14 @@ def __init__(self, resolution=(250, 122), colour='black', cs_pin=CS0_PIN, dc_pin self._i2c_bus = i2c_bus if resolution not in _RESOLUTION.keys(): - raise ValueError('Resolution {}x{} not supported!'.format(*resolution)) + raise ValueError("Resolution {}x{} not supported!".format(*resolution)) self.resolution = resolution self.width, self.height = resolution self.cols, self.rows, self.rotation, self.offset_x, self.offset_y = _RESOLUTION[resolution] - if colour not in ('red', 'black', 'yellow'): - raise ValueError('Colour {} is not supported!'.format(colour)) + if colour not in ("red", "black", "yellow"): + raise ValueError("Colour {} is not supported!".format(colour)) self.colour = colour self.eeprom = eeprom.read_eeprom(i2c_bus=i2c_bus) @@ -83,11 +85,11 @@ def __init__(self, resolution=(250, 122), colour='black', cs_pin=CS0_PIN, dc_pin if self.eeprom is not None: # Only support new-style variants if self.eeprom.display_variant not in (10, 11, 12): - raise RuntimeError('This driver is not compatible with your board.') + raise RuntimeError("This driver is not compatible with your board.") if self.eeprom.width != self.width or self.eeprom.height != self.height: pass # TODO flash correct heights to new EEPROMs - # raise ValueError('Supplied width/height do not match Inky: {}x{}'.format(self.eeprom.width, self.eeprom.height)) + # raise ValueError("Supplied width/height do not match Inky: {}x{}".format(self.eeprom.width, self.eeprom.height)) self.buf = numpy.zeros((self.cols, self.rows), dtype=numpy.uint8) @@ -97,6 +99,10 @@ def __init__(self, resolution=(250, 122), colour='black', cs_pin=CS0_PIN, dc_pin self.reset_pin = reset_pin self.busy_pin = busy_pin self.cs_pin = cs_pin + try: + self.cs_channel = [8, 7].index(cs_pin) + except ValueError: + self.cs_channel = 0 self.h_flip = h_flip self.v_flip = v_flip @@ -104,17 +110,17 @@ def __init__(self, resolution=(250, 122), colour='black', cs_pin=CS0_PIN, dc_pin self._gpio_setup = False self._luts = { - 'black': [ + "black": [ 0x02, 0x02, 0x01, 0x11, 0x12, 0x12, 0x22, 0x22, 0x66, 0x69, 0x69, 0x59, 0x58, 0x99, 0x99, 0x88, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xB4, 0x13, 0x51, 0x35, 0x51, 0x51, 0x19, 0x01, 0x00 ], - 'red': [ + "red": [ 0x02, 0x02, 0x01, 0x11, 0x12, 0x12, 0x22, 0x22, 0x66, 0x69, 0x69, 0x59, 0x58, 0x99, 0x99, 0x88, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xB4, 0x13, 0x51, 0x35, 0x51, 0x51, 0x19, 0x01, 0x00 ], - 'yellow': [ + "yellow": [ 0x02, 0x02, 0x01, 0x11, 0x12, 0x12, 0x22, 0x22, 0x66, 0x69, 0x69, 0x59, 0x58, 0x99, 0x99, 0x88, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xB4, 0x13, 0x51, 0x35, 0x51, 0x51, 0x19, 0x01, 0x00 @@ -125,29 +131,43 @@ def setup(self): """Set up Inky GPIO and reset display.""" if not self._gpio_setup: if self._gpio is None: - try: - import RPi.GPIO as GPIO - self._gpio = GPIO - except ImportError: - raise ImportError('This library requires the RPi.GPIO module\nInstall with: sudo apt install python-rpi.gpio') - self._gpio.setmode(self._gpio.BCM) - self._gpio.setwarnings(False) - self._gpio.setup(self.dc_pin, self._gpio.OUT, initial=self._gpio.LOW, pull_up_down=self._gpio.PUD_OFF) - self._gpio.setup(self.reset_pin, self._gpio.OUT, initial=self._gpio.HIGH, pull_up_down=self._gpio.PUD_OFF) - self._gpio.setup(self.busy_pin, self._gpio.IN, pull_up_down=self._gpio.PUD_OFF) + gpiochip = gpiodevice.find_chip_by_platform() + gpiodevice.friendly_errors = True + if gpiodevice.check_pins_available(gpiochip, { + "Chip Select": self.cs_pin, + "Data/Command": self.dc_pin, + "Reset": self.reset_pin, + "Busy": self.busy_pin + }): + self.cs_pin = gpiochip.line_offset_from_id(self.cs_pin) + self.dc_pin = gpiochip.line_offset_from_id(self.dc_pin) + self.reset_pin = gpiochip.line_offset_from_id(self.reset_pin) + self.busy_pin = gpiochip.line_offset_from_id(self.busy_pin) + + self._gpio = gpiochip.request_lines(consumer="inky", config={ + self.cs_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.ACTIVE, bias=Bias.DISABLED), + self.dc_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.INACTIVE), + self.reset_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.ACTIVE), + self.busy_pin: gpiod.LineSettings(direction=Direction.INPUT, edge_detection=Edge.FALLING, debounce_period=timedelta(milliseconds=10)) + }) if self._spi_bus is None: import spidev + self._spi_bus = spidev.SpiDev() - self._spi_bus.open(0, self.cs_pin) + self._spi_bus.open(0, self.cs_channel) + try: + self._spi_bus.no_cs = True + except OSError: + warnings.warn("SPI: Cannot disable chip-select!") self._spi_bus.max_speed_hz = 488000 self._gpio_setup = True - self._gpio.output(self.reset_pin, self._gpio.LOW) + self._gpio.set_value(self.reset_pin, Value.INACTIVE) time.sleep(0.5) - self._gpio.output(self.reset_pin, self._gpio.HIGH) + self._gpio.set_value(self.reset_pin, Value.ACTIVE) time.sleep(0.5) self._send_command(0x12) # Soft Reset @@ -156,11 +176,12 @@ def setup(self): def _busy_wait(self, timeout=5.0): """Wait for busy/wait pin.""" - t_start = time.time() - while self._gpio.input(self.busy_pin): - time.sleep(0.01) - if time.time() - t_start >= timeout: + if self._gpio.get_value(self.busy_pin) == Value.ACTIVE: + event = self._gpio.wait_edge_events(timedelta(seconds=timeout)) + if not event: raise RuntimeError("Timeout waiting for busy signal to clear.") + for event in self._gpio.read_edge_events(): + pass def _update(self, buf_a, buf_b, busy_wait=True): """Update display. @@ -178,7 +199,7 @@ def _update(self, buf_a, buf_b, busy_wait=True): self._send_command(ssd1608.WRITE_DUMMY, [0x1B]) # Set Line Width self._send_command(ssd1608.WRITE_GATELINE, [0x0B]) - # Data entry squence (scan direction leftward and downward) + # Data entry sequence (scan direction leftward and downward) self._send_command(ssd1608.DATA_MODE, [0x03]) # Set ram X start and end position xposBuf = [0x00, self.cols // 8 - 1] @@ -194,10 +215,10 @@ def _update(self, buf_a, buf_b, busy_wait=True): if self.border_colour == self.BLACK: self._send_command(ssd1608.WRITE_BORDER, 0b00000000) # GS Transition + Waveform 00 + GSA 0 + GSB 0 - elif self.border_colour == self.RED and self.colour == 'red': + elif self.border_colour == self.RED and self.colour == "red": self._send_command(ssd1608.WRITE_BORDER, 0b00000110) # GS Transition + Waveform 01 + GSA 1 + GSB 0 - elif self.border_colour == self.YELLOW and self.colour == 'yellow': + elif self.border_colour == self.YELLOW and self.colour == "yellow": self._send_command(ssd1608.WRITE_BORDER, 0b00001111) # GS Transition + Waveform 11 + GSA 1 + GSB 1 elif self.border_colour == self.WHITE: @@ -255,8 +276,22 @@ def set_border(self, colour): def set_image(self, image): """Copy an image to the display.""" + image = image.resize((self.width, self.height)) + + if not image.mode == "P": + palette_image = Image.new("P", (1, 1)) + r, g, b = 0, 0, 0 + if self.colour == "red": + r = 255 + if self.colour == "yellow": + r = g = 255 + palette_image.putpalette([255, 255, 255, 0, 0, 0, r, g, b] + [0, 0, 0] * 252) + image.load() + image = image.im.convert("P", True, palette_image.im) + canvas = Image.new("P", (self.rows, self.cols)) - canvas.paste(image, (self.offset_x, self.offset_y)) + width, height = image.size + canvas.paste(image, (self.offset_x, self.offset_y, width + self.offset_x, height + self.offset_y)) self.buf = numpy.array(canvas, dtype=numpy.uint8).reshape((self.cols, self.rows)) def _spi_write(self, dc, values): @@ -266,13 +301,16 @@ def _spi_write(self, dc, values): :param values: list of values to write """ - self._gpio.output(self.dc_pin, dc) + self._gpio.set_value(self.cs_pin, Value.INACTIVE) + self._gpio.set_value(self.dc_pin, Value.ACTIVE if dc else Value.INACTIVE) try: self._spi_bus.xfer3(values) except AttributeError: for x in range(((len(values) - 1) // _SPI_CHUNK_SIZE) + 1): offset = x * _SPI_CHUNK_SIZE - self._spi_bus.xfer(values[offset:offset + _SPI_CHUNK_SIZE]) + self._spi_bus.xfer(values[offset : offset + _SPI_CHUNK_SIZE]) + + self._gpio.set_value(self.cs_pin, Value.ACTIVE) def _send_command(self, command, data=None): """Send command over SPI. diff --git a/library/inky/inky_ssd1683.py b/inky/inky_ssd1683.py similarity index 68% rename from library/inky/inky_ssd1683.py rename to inky/inky_ssd1683.py index fcc15408..66e39f33 100644 --- a/library/inky/inky_ssd1683.py +++ b/inky/inky_ssd1683.py @@ -1,25 +1,26 @@ """Inky e-Ink Display Driver.""" import time +from datetime import timedelta +import gpiod +import gpiodevice +import numpy +from gpiod.line import Bias, Direction, Edge, Value from PIL import Image -from . import eeprom, ssd1683 -try: - import numpy -except ImportError: - raise ImportError('This library requires the numpy module\nInstall with: sudo apt install python-numpy') +from . import eeprom, ssd1683 WHITE = 0 BLACK = 1 RED = YELLOW = 2 -RESET_PIN = 27 -BUSY_PIN = 17 -DC_PIN = 22 +RESET_PIN = 27 # PIN13 +BUSY_PIN = 17 # PIN11 +DC_PIN = 22 # PIN15 MOSI_PIN = 10 SCLK_PIN = 11 -CS0_PIN = 0 +CS0_PIN = 8 SUPPORTED_DISPLAYS = 17, 18, 19 @@ -40,7 +41,7 @@ class Inky: RED = 2 YELLOW = 2 - def __init__(self, resolution=(400, 300), colour='black', cs_pin=CS0_PIN, dc_pin=DC_PIN, reset_pin=RESET_PIN, busy_pin=BUSY_PIN, h_flip=False, v_flip=False, spi_bus=None, i2c_bus=None, gpio=None): # noqa: E501 + def __init__(self, resolution=(400, 300), colour="black", cs_pin=CS0_PIN, dc_pin=DC_PIN, reset_pin=RESET_PIN, busy_pin=BUSY_PIN, h_flip=False, v_flip=False, spi_bus=None, i2c_bus=None, gpio=None): # noqa: E501 """Initialise an Inky Display. :param resolution: (width, height) in pixels, default: (400, 300) @@ -57,14 +58,14 @@ def __init__(self, resolution=(400, 300), colour='black', cs_pin=CS0_PIN, dc_pin self._i2c_bus = i2c_bus if resolution not in _RESOLUTION.keys(): - raise ValueError('Resolution {}x{} not supported!'.format(*resolution)) + raise ValueError("Resolution {}x{} not supported!".format(*resolution)) self.resolution = resolution self.width, self.height = resolution self.cols, self.rows, self.rotation, self.offset_x, self.offset_y = _RESOLUTION[resolution] - if colour not in ('red', 'black', 'yellow'): - raise ValueError('Colour {} is not supported!'.format(colour)) + if colour not in ("red", "black", "yellow"): + raise ValueError("Colour {} is not supported!".format(colour)) self.colour = colour self.eeprom = eeprom.read_eeprom(i2c_bus=i2c_bus) @@ -73,9 +74,9 @@ def __init__(self, resolution=(400, 300), colour='black', cs_pin=CS0_PIN, dc_pin if self.eeprom is not None: # Only support SSD1683 variants if self.eeprom.display_variant not in SUPPORTED_DISPLAYS: - raise RuntimeError('This driver is not compatible with your board.') + raise RuntimeError("This driver is not compatible with your board.") if self.eeprom.width != self.width or self.eeprom.height != self.height: - raise ValueError('Supplied width/height do not match Inky: {}x{}'.format(self.eeprom.width, self.eeprom.height)) + raise ValueError("Supplied width/height do not match Inky: {}x{}".format(self.eeprom.width, self.eeprom.height)) self.buf = numpy.zeros((self.cols, self.rows), dtype=numpy.uint8) @@ -85,6 +86,10 @@ def __init__(self, resolution=(400, 300), colour='black', cs_pin=CS0_PIN, dc_pin self.reset_pin = reset_pin self.busy_pin = busy_pin self.cs_pin = cs_pin + try: + self.cs_channel = [8, 7].index(cs_pin) + except ValueError: + self.cs_channel = 0 self.h_flip = h_flip self.v_flip = v_flip @@ -92,17 +97,17 @@ def __init__(self, resolution=(400, 300), colour='black', cs_pin=CS0_PIN, dc_pin self._gpio_setup = False self._luts = { - 'black': [ + "black": [ 0x02, 0x02, 0x01, 0x11, 0x12, 0x12, 0x22, 0x22, 0x66, 0x69, 0x69, 0x59, 0x58, 0x99, 0x99, 0x88, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xB4, 0x13, 0x51, 0x35, 0x51, 0x51, 0x19, 0x01, 0x00 ], - 'red': [ + "red": [ 0x02, 0x02, 0x01, 0x11, 0x12, 0x12, 0x22, 0x22, 0x66, 0x69, 0x69, 0x59, 0x58, 0x99, 0x99, 0x88, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xB4, 0x13, 0x51, 0x35, 0x51, 0x51, 0x19, 0x01, 0x00 ], - 'yellow': [ + "yellow": [ 0x02, 0x02, 0x01, 0x11, 0x12, 0x12, 0x22, 0x22, 0x66, 0x69, 0x69, 0x59, 0x58, 0x99, 0x99, 0x88, 0x00, 0x00, 0x00, 0x00, 0xF8, 0xB4, 0x13, 0x51, 0x35, 0x51, 0x51, 0x19, 0x01, 0x00 @@ -113,42 +118,53 @@ def setup(self): """Set up Inky GPIO and reset display.""" if not self._gpio_setup: if self._gpio is None: - try: - import RPi.GPIO as GPIO - self._gpio = GPIO - except ImportError: - raise ImportError('This library requires the RPi.GPIO module\nInstall with: sudo apt install python-rpi.gpio') - self._gpio.setmode(self._gpio.BCM) - self._gpio.setwarnings(False) - self._gpio.setup(self.dc_pin, self._gpio.OUT, initial=self._gpio.LOW, pull_up_down=self._gpio.PUD_OFF) - self._gpio.setup(self.reset_pin, self._gpio.OUT, initial=self._gpio.HIGH, pull_up_down=self._gpio.PUD_OFF) - self._gpio.setup(self.busy_pin, self._gpio.IN, pull_up_down=self._gpio.PUD_OFF) + gpiochip = gpiodevice.find_chip_by_platform() + gpiodevice.friendly_errors = True + if gpiodevice.check_pins_available(gpiochip, { + "Chip Select": self.cs_pin, + "Data/Command": self.dc_pin, + "Reset": self.reset_pin, + "Busy": self.busy_pin + }): + self.cs_pin = gpiochip.line_offset_from_id(self.cs_pin) + self.dc_pin = gpiochip.line_offset_from_id(self.dc_pin) + self.reset_pin = gpiochip.line_offset_from_id(self.reset_pin) + self.busy_pin = gpiochip.line_offset_from_id(self.busy_pin) + + self._gpio = gpiochip.request_lines(consumer="inky", config={ + self.cs_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.INACTIVE, bias=Bias.DISABLED), + self.dc_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.INACTIVE, bias=Bias.DISABLED), + self.reset_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.ACTIVE, bias=Bias.DISABLED), + self.busy_pin: gpiod.LineSettings(direction=Direction.INPUT, edge_detection=Edge.FALLING, bias=Bias.DISABLED) + }) if self._spi_bus is None: import spidev + self._spi_bus = spidev.SpiDev() - self._spi_bus.open(0, self.cs_pin) + self._spi_bus.open(0, self.cs_channel) self._spi_bus.max_speed_hz = 10000000 # Should be good for 20MHz according to datasheet self._gpio_setup = True - self._gpio.output(self.reset_pin, self._gpio.LOW) + self._gpio.set_value(self.reset_pin, Value.INACTIVE) time.sleep(0.5) - self._gpio.output(self.reset_pin, self._gpio.HIGH) + self._gpio.set_value(self.reset_pin, Value.ACTIVE) time.sleep(0.5) self._send_command(0x12) # Soft Reset - time.sleep(1.0) + time.sleep(1.0) # Required, or we'll miss buf_a (black) self._busy_wait() - def _busy_wait(self, timeout=5.0): + def _busy_wait(self, timeout=30.0): """Wait for busy/wait pin.""" - t_start = time.time() - while self._gpio.input(self.busy_pin): - time.sleep(0.01) - if time.time() - t_start >= timeout: + if self._gpio.get_value(self.busy_pin) == Value.ACTIVE: + event = self._gpio.wait_edge_events(timedelta(seconds=timeout)) + if not event: raise RuntimeError("Timeout waiting for busy signal to clear.") + for event in self._gpio.read_edge_events(): + pass def _update(self, buf_a, buf_b, busy_wait=True): """Update display. @@ -166,7 +182,7 @@ def _update(self, buf_a, buf_b, busy_wait=True): self._send_command(ssd1683.WRITE_DUMMY, [0x1B]) # Set Line Width self._send_command(ssd1683.WRITE_GATELINE, [0x0B]) - # Data entry squence (scan direction leftward and downward) + # Data entry sequence (scan direction leftward and downward) self._send_command(ssd1683.DATA_MODE, [0x03]) # Set ram X start and end position xposBuf = [0x00, self.cols // 8 - 1] @@ -182,10 +198,10 @@ def _update(self, buf_a, buf_b, busy_wait=True): if self.border_colour == self.BLACK: self._send_command(ssd1683.WRITE_BORDER, 0b00000000) # GS Transition + Waveform 00 + GSA 0 + GSB 0 - elif self.border_colour == self.RED and self.colour == 'red': + elif self.border_colour == self.RED and self.colour == "red": self._send_command(ssd1683.WRITE_BORDER, 0b00000110) # GS Transition + Waveform 01 + GSA 1 + GSB 0 - elif self.border_colour == self.YELLOW and self.colour == 'yellow': + elif self.border_colour == self.YELLOW and self.colour == "yellow": self._send_command(ssd1683.WRITE_BORDER, 0b00001111) # GS Transition + Waveform 11 + GSA 1 + GSB 1 elif self.border_colour == self.WHITE: @@ -243,8 +259,20 @@ def set_border(self, colour): def set_image(self, image): """Copy an image to the display.""" + if not image.mode == "P": + palette_image = Image.new("P", (1, 1)) + r, g, b = 0, 0, 0 + if self.colour == "red": + r = 255 + if self.colour == "yellow": + r = g = 255 + palette_image.putpalette([255, 255, 255, 0, 0, 0, r, g, b] + [0, 0, 0] * 252) + image.load() + image = image.im.convert("P", True, palette_image.im) + canvas = Image.new("P", (self.cols, self.rows)) - canvas.paste(image, (self.offset_x, self.offset_y)) + width, height = image.size + canvas.paste(image, (self.offset_x, self.offset_y, width, height)) self.buf = numpy.array(canvas, dtype=numpy.uint8).reshape((self.rows, self.cols)) def _spi_write(self, dc, values): @@ -254,7 +282,9 @@ def _spi_write(self, dc, values): :param values: list of values to write """ - self._gpio.output(self.dc_pin, dc) + self._gpio.set_value(self.cs_pin, Value.INACTIVE) + self._gpio.set_value(self.dc_pin, Value.ACTIVE if dc else Value.INACTIVE) + try: self._spi_bus.xfer3(values) except AttributeError: @@ -262,6 +292,8 @@ def _spi_write(self, dc, values): offset = x * _SPI_CHUNK_SIZE self._spi_bus.xfer(values[offset:offset + _SPI_CHUNK_SIZE]) + self._gpio.set_value(self.cs_pin, Value.ACTIVE) + def _send_command(self, command, data=None): """Send command over SPI. diff --git a/library/inky/inky_uc8159.py b/inky/inky_uc8159.py similarity index 78% rename from library/inky/inky_uc8159.py rename to inky/inky_uc8159.py index 31534c8c..1b0da806 100644 --- a/library/inky/inky_uc8159.py +++ b/inky/inky_uc8159.py @@ -1,20 +1,17 @@ """Inky e-Ink Display Driver.""" -import time import struct +import time import warnings +from datetime import timedelta -try: - from PIL import Image -except ImportError: - Image = None +import gpiod +import gpiodevice +import numpy +from gpiod.line import Bias, Direction, Edge, Value +from PIL import Image from . import eeprom -try: - import numpy -except ImportError: - raise ImportError('This library requires the numpy module\nInstall with: sudo apt install python-numpy') - BLACK = 0 WHITE = 1 GREEN = 2 @@ -46,9 +43,9 @@ [255, 255, 255] ] -RESET_PIN = 27 -BUSY_PIN = 17 -DC_PIN = 22 +RESET_PIN = 27 # PIN13 +BUSY_PIN = 17 # PIN11 +DC_PIN = 22 # PIN15 MOSI_PIN = 10 SCLK_PIN = 11 @@ -131,7 +128,7 @@ class Inky: [177, 106, 73], [255, 255, 255]] - def __init__(self, resolution=None, colour='multi', cs_pin=CS0_PIN, dc_pin=DC_PIN, reset_pin=RESET_PIN, busy_pin=BUSY_PIN, h_flip=False, v_flip=False, spi_bus=None, i2c_bus=None, gpio=None): # noqa: E501 + def __init__(self, resolution=None, colour="multi", cs_pin=CS0_PIN, dc_pin=DC_PIN, reset_pin=RESET_PIN, busy_pin=BUSY_PIN, h_flip=False, v_flip=False, spi_bus=None, i2c_bus=None, gpio=None): # noqa: E501 """Initialise an Inky Display. :param resolution: (width, height) in pixels, default: (600, 448) @@ -157,7 +154,7 @@ def __init__(self, resolution=None, colour='multi', cs_pin=CS0_PIN, dc_pin=DC_PI resolution = _RESOLUTION_5_7_INCH if resolution not in _RESOLUTION.keys(): - raise ValueError('Resolution {}x{} not supported!'.format(*resolution)) + raise ValueError(f"Resolution {resolution[0]}x{resolution[1]} not supported!") self.resolution = resolution self.width, self.height = resolution @@ -165,8 +162,8 @@ def __init__(self, resolution=None, colour='multi', cs_pin=CS0_PIN, dc_pin=DC_PI self.border_colour = WHITE self.cols, self.rows, self.rotation, self.offset_x, self.offset_y, self.resolution_setting = _RESOLUTION[resolution] - if colour not in ('multi'): - raise ValueError('Colour {} is not supported!'.format(colour)) + if colour not in ("multi"): + raise ValueError(f"Colour {colour} is not supported!") self.colour = colour self.lut = colour @@ -189,51 +186,62 @@ def __init__(self, resolution=None, colour='multi', cs_pin=CS0_PIN, dc_pin=DC_PI self._luts = None - def _palette_blend(self, saturation, dtype='uint8'): + def _palette_blend(self, saturation, dtype="uint8"): saturation = float(saturation) palette = [] for i in range(7): rs, gs, bs = [c * saturation for c in self.SATURATED_PALETTE[i]] rd, gd, bd = [c * (1.0 - saturation) for c in self.DESATURATED_PALETTE[i]] - if dtype == 'uint8': + if dtype == "uint8": palette += [int(rs + rd), int(gs + gd), int(bs + bd)] - if dtype == 'uint24': + if dtype == "uint24": palette += [(int(rs + rd) << 16) | (int(gs + gd) << 8) | int(bs + bd)] - if dtype == 'uint8': + if dtype == "uint8": palette += [255, 255, 255] - if dtype == 'uint24': - palette += [0xffffff] + if dtype == "uint24": + palette += [0xFFFFFF] return palette def setup(self): """Set up Inky GPIO and reset display.""" if not self._gpio_setup: if self._gpio is None: - try: - import RPi.GPIO as GPIO - self._gpio = GPIO - except ImportError: - raise ImportError('This library requires the RPi.GPIO module\nInstall with: sudo apt install python-rpi.gpio') - self._gpio.setmode(self._gpio.BCM) - self._gpio.setwarnings(False) - self._gpio.setup(self.cs_pin, self._gpio.OUT, initial=self._gpio.HIGH) - self._gpio.setup(self.dc_pin, self._gpio.OUT, initial=self._gpio.LOW, pull_up_down=self._gpio.PUD_OFF) - self._gpio.setup(self.reset_pin, self._gpio.OUT, initial=self._gpio.HIGH, pull_up_down=self._gpio.PUD_OFF) - self._gpio.setup(self.busy_pin, self._gpio.IN, pull_up_down=self._gpio.PUD_OFF) + gpiochip = gpiodevice.find_chip_by_platform() + gpiodevice.friendly_errors = True + if gpiodevice.check_pins_available(gpiochip, { + "Chip Select": self.cs_pin, + "Data/Command": self.dc_pin, + "Reset": self.reset_pin, + "Busy": self.busy_pin + }): + self.cs_pin = gpiochip.line_offset_from_id(self.cs_pin) + self.dc_pin = gpiochip.line_offset_from_id(self.dc_pin) + self.reset_pin = gpiochip.line_offset_from_id(self.reset_pin) + self.busy_pin = gpiochip.line_offset_from_id(self.busy_pin) + self._gpio = gpiochip.request_lines(consumer="inky", config={ + self.cs_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.ACTIVE, bias=Bias.DISABLED), + self.dc_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.INACTIVE, bias=Bias.DISABLED), + self.reset_pin: gpiod.LineSettings(direction=Direction.OUTPUT, output_value=Value.ACTIVE, bias=Bias.DISABLED), + self.busy_pin: gpiod.LineSettings(direction=Direction.INPUT, edge_detection=Edge.RISING, debounce_period=timedelta(milliseconds=10), bias=Bias.DISABLED) + }) if self._spi_bus is None: import spidev self._spi_bus = spidev.SpiDev() self._spi_bus.open(0, self.cs_channel) - self._spi_bus.no_cs = True + try: + self._spi_bus.no_cs = True + except OSError: + warnings.warn("SPI: Cannot disable chip-select!") self._spi_bus.max_speed_hz = 3000000 self._gpio_setup = True - self._gpio.output(self.reset_pin, self._gpio.LOW) + self._gpio.set_value(self.reset_pin, Value.INACTIVE) + time.sleep(0.1) + self._gpio.set_value(self.reset_pin, Value.ACTIVE) time.sleep(0.1) - self._gpio.output(self.reset_pin, self._gpio.HIGH) self._busy_wait(1.0) @@ -319,18 +327,18 @@ def _busy_wait(self, timeout=40.0): # If the busy_pin is *high* (pulled up by host) # then assume we're not getting a signal from inky # and wait the timeout period to be safe. - if self._gpio.input(self.busy_pin): - warnings.warn("Busy Wait: Held high. Waiting for {:0.2f}s".format(timeout)) + if self._gpio.get_value(self.busy_pin) == Value.ACTIVE: + warnings.warn(f"Busy Wait: Held high. Waiting for {timeout:0.2f}s") time.sleep(timeout) return - # If the busy_pin is *low* (pulled down by inky) - # then wait for it to high. - t_start = time.time() - while not self._gpio.input(self.busy_pin): - time.sleep(0.01) - if time.time() - t_start >= timeout: - warnings.warn("Busy Wait: Timed out after {:0.2f}s".format(time.time() - t_start)) + event = self._gpio.wait_edge_events(timedelta(seconds=timeout)) + if not event: + warnings.warn(f"Busy Wait: Timed out after {timeout:0.2f}s") + return + + for event in self._gpio.read_edge_events(): + if event.Type == Edge.RISING: return def _update(self, buf): @@ -338,12 +346,8 @@ def _update(self, buf): Dispatches display update to correct driver. - :param buf_a: Black/White pixels - :param buf_b: Yellow/Red pixels - """ self.setup() - self._send_command(UC8159_DTM1, buf) self._send_command(UC8159_PON) @@ -386,7 +390,7 @@ def show(self, busy_wait=True): buf = ((buf[::2] << 4) & 0xF0) | (buf[1::2] & 0x0F) - self._update(buf.astype('uint8').tolist()) + self._update(buf.astype("uint8").tolist()) def set_border(self, colour): """Set the border colour.""" @@ -401,10 +405,8 @@ def set_image(self, image, saturation=0.5): """ if not image.size == (self.width, self.height): - raise ValueError("Image must be ({}x{}) pixels!".format(self.width, self.height)) + raise ValueError(f"Image must be ({self.width}x{self.height}) pixels!") if not image.mode == "P": - if Image is None: - raise RuntimeError("PIL is required for converting images: sudo apt install python-pil python3-pil") palette = self._palette_blend(saturation) # Image size doesn't matter since it's just the palette we're using palette_image = Image.new("P", (1, 1)) @@ -422,10 +424,10 @@ def _spi_write(self, dc, values): :param values: list of values to write """ - self._gpio.output(self.cs_pin, 0) - self._gpio.output(self.dc_pin, dc) + self._gpio.set_value(self.cs_pin, Value.INACTIVE) + self._gpio.set_value(self.dc_pin, Value.ACTIVE if dc else Value.INACTIVE) - if type(values) is str: + if isinstance(values, str): values = [ord(c) for c in values] try: @@ -433,8 +435,9 @@ def _spi_write(self, dc, values): except AttributeError: for x in range(((len(values) - 1) // _SPI_CHUNK_SIZE) + 1): offset = x * _SPI_CHUNK_SIZE - self._spi_bus.xfer(values[offset:offset + _SPI_CHUNK_SIZE]) - self._gpio.output(self.cs_pin, 1) + self._spi_bus.xfer(values[offset : offset + _SPI_CHUNK_SIZE]) + + self._gpio.set_value(self.cs_pin, Value.ACTIVE) def _send_command(self, command, data=None): """Send command over SPI. diff --git a/library/inky/mock.py b/inky/mock.py similarity index 79% rename from library/inky/mock.py rename to inky/mock.py index 417ecd0c..4254e546 100644 --- a/library/inky/mock.py +++ b/inky/mock.py @@ -1,18 +1,17 @@ """PIL/Tkinter based simulator for InkyWHAT and InkyWHAT.""" import numpy - -from . import inky -from . import inky_uc8159 +from . import inky, inky_uc8159 class InkyMock(inky.Inky): """Base simulator class for Inky.""" - def __init__(self, colour, h_flip=False, v_flip=False): + def __init__(self, colour, h_flip=False, v_flip=False, resolution=None): """Initialise an Inky pHAT Display. :param colour: one of red, black or yellow, default: black + :param resolution: (width, height) in pixels """ global tkinter, ImageTk, Image @@ -20,17 +19,18 @@ def __init__(self, colour, h_flip=False, v_flip=False): try: import tkinter except ImportError: - raise ImportError('Simulation requires tkinter') + raise ImportError("Simulation requires tkinter") try: - from PIL import ImageTk, Image + from PIL import Image, ImageTk except ImportError: - raise ImportError('Simulation requires PIL ImageTk and Image') + raise ImportError("Simulation requires PIL ImageTk and Image") - resolution = (self.WIDTH, self.HEIGHT) + if resolution is None: + resolution = (self.WIDTH, self.HEIGHT) if resolution not in inky._RESOLUTION.keys(): - raise ValueError('Resolution {}x{} not supported!'.format(*resolution)) + raise ValueError("Resolution {}x{} not supported!".format(*resolution)) self.resolution = resolution self.width, self.height = resolution @@ -38,8 +38,8 @@ def __init__(self, colour, h_flip=False, v_flip=False): self.buf = numpy.zeros((self.height, self.width), dtype=numpy.uint8) - if colour not in ('red', 'black', 'yellow', 'multi'): - raise ValueError('Colour {} is not supported!'.format(colour)) + if colour not in ("red", "black", "yellow", "multi"): + raise ValueError("Colour {} is not supported!".format(colour)) self.colour = colour @@ -68,20 +68,20 @@ def __init__(self, colour, h_flip=False, v_flip=False): # yellow color value: screen capture from # https://www.thoughtsmakethings.com/Pimoroni-Inky-pHAT - self.c_palette = {'black': bw_inky_palette, - 'red': red_inky_palette, - 'yellow': ylw_inky_palette, - 'multi': impression_palette} + self.c_palette = {"black": bw_inky_palette, + "red": red_inky_palette, + "yellow": ylw_inky_palette, + "multi": impression_palette} self._tk_done = False self.tk_root = tkinter.Tk() - self.tk_root.title('Inky Preview') - self.tk_root.geometry('{}x{}'.format(self.WIDTH, self.HEIGHT)) - self.tk_root.aspect(self.WIDTH, self.HEIGHT, self.WIDTH, self.HEIGHT) - self.tk_root.protocol('WM_DELETE_WINDOW', self._close_window) + self.tk_root.title("Inky Preview") + self.tk_root.geometry("{}x{}".format(self.width, self.height)) + self.tk_root.aspect(self.width, self.height, self.width, self.height) + self.tk_root.protocol("WM_DELETE_WINDOW", self._close_window) self.cv = None - self.cvh = self.HEIGHT - self.cvw = self.WIDTH + self.cvh = self.height + self.cvw = self.width def wait_for_window_close(self): """Wait until the Tkinter window has closed.""" @@ -103,7 +103,7 @@ def resize(self, event): self.cv.config(width=self.cvw, height=self.cvh) image = self.disp_img_copy.resize([self.cvw, self.cvh]) self.photo = ImageTk.PhotoImage(image) - self.cv.itemconfig(self.cvhandle, image=self.photo, anchor='nw') + self.cv.itemconfig(self.cvhandle, image=self.photo, anchor="nw") self.tk_root.update() def _send_command(self, command, data=None): @@ -113,17 +113,17 @@ def _simulate(self, region): pass def _display(self, region): - im = Image.fromarray(region, 'P') + im = Image.fromarray(region, "P") im.putpalette(self.c_palette[self.colour]) self.disp_img_copy = im.copy() # can be changed due to window resizing, so copy image = self.disp_img_copy.resize([self.cvw, self.cvh]) self.photo = ImageTk.PhotoImage(image) if self.cv is None: - self.cv = tkinter.Canvas(self.tk_root, width=self.WIDTH, height=self.HEIGHT) - self.cv.pack(side='top', fill='both', expand='yes') - self.cvhandle = self.cv.create_image(0, 0, image=self.photo, anchor='nw') - self.cv.bind('', self.resize) + self.cv = tkinter.Canvas(self.tk_root, width=self.width, height=self.height) + self.cv.pack(side="top", fill="both", expand="yes") + self.cvhandle = self.cv.create_image(0, 0, image=self.photo, anchor="nw") + self.cv.bind("", self.resize) self.tk_root.update() def show(self, busy_wait=True): @@ -132,7 +132,7 @@ def show(self, busy_wait=True): :param busy_wait: Ignored. Updates are simulated and instant. """ - print('>> Simulating {} {}x{}...'.format(self.colour, self.WIDTH, self.HEIGHT)) + print(">> Simulating {} {}x{}...".format(self.colour, self.width, self.height)) region = self.buf @@ -236,16 +236,20 @@ class InkyMockImpression(InkyMock): [177, 106, 73], [255, 255, 255]] - def __init__(self): - """Initialize a new mock Inky Impression.""" - InkyMock.__init__(self, 'multi') + def __init__(self, resolution=None): + """Initialize a new mock Inky Impression. + + :param resolution: (width, height) in pixels, default: (600, 448) + + """ + InkyMock.__init__(self, "multi", resolution=resolution) def _simulate(self, region): self._display(region) def set_pixel(self, x, y, v): """Set a single pixel on the display.""" - self.buf[y][x] = v & 0xf + self.buf[y][x] = v & 0xF def set_image(self, image, saturation=0.5): """Copy an image to the display. diff --git a/library/inky/phat.py b/inky/phat.py similarity index 100% rename from library/inky/phat.py rename to inky/phat.py diff --git a/library/inky/ssd1608.py b/inky/ssd1608.py similarity index 100% rename from library/inky/ssd1608.py rename to inky/ssd1608.py diff --git a/library/inky/ssd1683.py b/inky/ssd1683.py similarity index 100% rename from library/inky/ssd1683.py rename to inky/ssd1683.py diff --git a/library/inky/what.py b/inky/what.py similarity index 100% rename from library/inky/what.py rename to inky/what.py diff --git a/install.sh b/install.sh index cbd9bf3a..3db90bc0 100755 --- a/install.sh +++ b/install.sh @@ -1,21 +1,370 @@ #!/bin/bash +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" +VENV_BASH_SNIPPET="$RESOURCES_TOP_DIR/auto_venv.sh" +VENV_DIR="$HOME/.virtualenvs/pimoroni" +USAGE="./install.sh (--unstable)" +POSITIONAL_ARGS=() +FORCE=false +UNSTABLE=false +PYTHON="python" +CMD_ERRORS=false -printf "Inky Python Library: Installer\n\n" -if [ $(id -u) -ne 0 ]; then - printf "Script must be run as root. Try 'sudo ./install.sh'\n" +user_check() { + if [ "$(id -u)" -eq 0 ]; then + fatal "Script should not be run as root. Try './install.sh'\n" + fi +} + +confirm() { + if $FORCE; then + true + else + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi + fi +} + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + 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 +} -function py_install() { - if [ -f "$1" ]; then - VERSION=`$1 --version 2>&1` - printf "Installing for $VERSION..\n" - $1 -m pip install --no-binary .[example-depends] ./library/ +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" } -py_install /usr/bin/python -py_install /usr/bin/python3 +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_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_DIR/$CONFIG_FILE" >> "$UNINSTALLER" + fi + fi +} + +function apt_pkg_install { + 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 %s\n" "$PACKAGE" + dpkg -L "$PACKAGE" > /dev/null 2>&1 + if [ "$?" == "1" ]; then + PACKAGES_NEEDED+=("$PACKAGE") + fi + done + PACKAGES="${PACKAGES_NEEDED[*]}" + if ! [ "$PACKAGES" == "" ]; then + 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" + 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 +} + +while [[ $# -gt 0 ]]; do + K="$1" + case $K in + -u|--unstable) + UNSTABLE=true + shift + ;; + -f|--force) + FORCE=true + shift + ;; + -p|--python) + PYTHON=$2 + shift + shift + ;; + *) + if [[ $1 == -* ]]; then + printf "Unrecognised option: %s\n" "$1"; + printf "Usage: %s\n" "$USAGE"; + exit 1 + fi + POSITIONAL_ARGS+=("$1") + shift + esac +done + +printf "Installing %s...\n\n" "$LIBRARY_NAME" + +user_check +venv_check + +if [ ! -f "$(which "$PYTHON")" ]; then + fatal "Python path %s not found!\n" "$PYTHON" +fi + +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" +printf "It's recommended you run these steps manually.\n" +printf "If you want to run the full script, open it in\n" +printf "an editor and remove 'exit 1' from below.\n" +exit 1 +source $VIRTUAL_ENV/bin/activate +EOF + +printf "\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 + 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" +fi + +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 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 + if [[ ! "$CMD" == printf* ]]; then + printf "Running: \"%s\"\n" "$CMD" + fi + eval "$CMD" + check_for_error +done -printf "Done!\n" +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_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" + success "Done!" + fi +fi + +printf "\n" + +# Use pdoc to generate basic documentation from the installed module + +if confirm "Would you like to generate documentation?"; then + inform "Installing pdoc. Please wait..." + pip_pkg_install pdoc + inform "Generating documentation.\n" + if $PYTHON -m pdoc "$LIBRARY_NAME" -o "$RESOURCES_DIR/docs" > /dev/null; then + inform "Documentation saved to $RESOURCES_DIR/docs" + success "Done!" + else + warning "Error: Failed to generate documentation." + fi +fi + +printf "\n" + +if [ "$CMD_ERRORS" = true ]; then + warning "One or more setup commands appear to have failed." + printf "This might prevent things from working properly.\n" + printf "Make sure your OS is up to date and try re-running this installer.\n" + printf "If things still don't work, report this or find help at %s.\n\n" "$GITHUB_URL" +else + success "\nAll done!" +fi + +printf "If this is your first time installing you should reboot for hardware changes to take effect.\n" +printf "Find uninstall steps in %s\n\n" "$UNINSTALLER" + +if [ "$CMD_ERRORS" = true ]; then + exit 1 +else + exit 0 +fi diff --git a/library/LICENSE.txt b/library/LICENSE.txt deleted file mode 100644 index aed751a0..00000000 --- a/library/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2018 Pimoroni Ltd. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/library/README.md b/library/README.md deleted file mode 100644 index 2cf1f74b..00000000 --- a/library/README.md +++ /dev/null @@ -1,117 +0,0 @@ -# Inky - -[![Build Status](https://travis-ci.com/pimoroni/inky.svg?branch=master)](https://travis-ci.com/pimoroni/inky) -[![Coverage Status](https://coveralls.io/repos/github/pimoroni/inky/badge.svg?branch=master)](https://coveralls.io/github/pimoroni/inky?branch=master) -[![PyPi Package](https://img.shields.io/pypi/v/inky.svg)](https://pypi.python.org/pypi/inky) -[![Python Versions](https://img.shields.io/pypi/pyversions/inky.svg)](https://pypi.python.org/pypi/inky) - -Python library for [Inky pHAT](https://shop.pimoroni.com/products/inky-phat), [Inky wHAT](https://shop.pimoroni.com/products/inky-what) and [Inky Impression](https://shop.pimoroni.com/?q=inky+impression) e-paper displays for Raspberry Pi. - -## Inky pHAT - -[Inky pHAT](https://shop.pimoroni.com/products/inky-phat) is a 250x122 pixel e-paper display, available in red/black/white, yellow/black/white and black/white. It's great for nametags and displaying very low frequency information such as a daily calendar or weather overview. - - -## Inky wHAT - -[Inky wHAT](https://shop.pimoroni.com/products/inky-what) is a 400x300 pixel e-paper display available in red/black/white, yellow/black/white and black/white. It's got tons of resolution for detailed daily to-do lists, multi-day weather forecasts, bus timetables and more. - -## Inky Impression - -[Inky Impression](https://shop.pimoroni.com/?q=inky+impression) is our line of glorious 7 colour eInk displays, available in [4"](https://shop.pimoroni.com/products/inky-impression-4) (640 x 400 pixel) [5.7"](https://shop.pimoroni.com/products/inky-impression-5-7) (600 x 448 pixel) and [7.3"](https://shop.pimoroni.com/products/inky-impression-7-3) (800 x 480 pixel) flavours. They're packed with strong colours and perfect for displaying striking graphics or lots of data. - -# Installation - -First, make sure you have I2C and SPI enabled in `sudo raspi-config`. - -The Python pip package is named inky, on the Raspberry Pi install with: - -``` -pip3 install inky[rpi,example-depends] -``` - -This will install Inky along with dependencies for the Raspberry Pi, plus fonts used by the examples. - -If you want to simulate Inky on your desktop, use: - -``` -pip3 install inky -``` - -You may need to use `sudo pip3` or `sudo pip` depending on your environment and Python version. - -# Usage - -The library should be run with Python 3. - -## Auto Setup - -Inky can try to automatically identify your board (from the information stored on its EEPROM) and set up accordingly. This is the easiest way to work with recent Inky displays. - -```python -from inky.auto import auto -display = auto() -``` - -You can then get the colour and resolution from the board: - -```python -display.colour -display.resolution -``` - -## Manual Setup - -If you have an older Inky without an EEPROM, you can specify the type manually. The Inky library contains modules for both the pHAT and wHAT, load the Inky pHAT one as follows: - -```python -from inky import InkyPHAT -``` - -You'll then need to pick your colour, one of 'red', 'yellow' or 'black' and instantiate the class: - -```python -display = InkyPHAT('red') -``` - -If you're using the wHAT you'll need to load the InkyWHAT class from the Inky library like so: - -```python -from inky import InkyWHAT -display = InkyWHAT('red') -``` - -Once you've initialised Inky, there are only three methods you need to be concerned with: - -## Set Image - -Set a PIL image, numpy array or list to Inky's internal buffer. The image dimensions should match the dimensions of the pHAT or wHAT you're using. - -```python -display.set_image(image) -``` - -You should use `PIL` to create an image. `PIL` provides an `ImageDraw` module which allow you to draw text, lines and shapes over your image. See: https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html - -## Set Border - -Set the border colour of you pHAT or wHAT. - -```python -display.set_border(colour) -``` - -`colour` should be one of `inky.RED`, `inky.YELLOW`, `inky.WHITE` or `inky.BLACK` with available colours depending on your display type. - -## Update The Display - -Once you've prepared and set your image, and chosen a border colour, you can update your e-ink display with: - -```python -display.show() -``` - - -# Migrating - -If you're migrating code from the old `inkyphat` library you'll find that much of the drawing and image manipulation functions have been removed from Inky. These functions were always supplied by PIL, and the recommended approach is to use PIL to create and prepare your image before setting it to Inky with `set_image()`. diff --git a/library/setup.cfg b/library/setup.cfg deleted file mode 100644 index 5c3c3ea2..00000000 --- a/library/setup.cfg +++ /dev/null @@ -1,11 +0,0 @@ -[flake8] -exclude = - test.py - .tox, - .eggs, - .git, - __pycache__, - build, - dist -ignore = - E501 diff --git a/library/setup.py b/library/setup.py deleted file mode 100755 index 6c442cce..00000000 --- a/library/setup.py +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env python3 - -""" -Copyright (c) 2017 Pimoroni. - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -of the Software, and to permit persons to whom the Software is furnished to do -so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -""" - -from setuptools import setup - -classifiers = [ - 'Development Status :: 5 - Production/Stable', - 'Operating System :: POSIX :: Linux', - 'License :: OSI Approved :: MIT License', - 'Intended Audience :: Developers', - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Topic :: Software Development', - 'Topic :: System :: Hardware' -] - -setup( - name='inky', - version='1.5.0', - author='Philip Howard', - author_email='phil@pimoroni.com', - description='Inky pHAT Driver', - long_description=open('README.md').read() + '\n' + open('CHANGELOG.txt').read(), - long_description_content_type="text/markdown", - license='MIT', - keywords='Raspberry Pi e-paper display driver', - url='http://www.pimoroni.com', - project_urls={'GitHub': 'https://www.github.com/pimoroni/inky'}, - classifiers=classifiers, - py_modules=[], - packages=['inky'], - include_package_data=True, - install_requires=['numpy', 'smbus2', 'spidev'], - extras_require={ - 'rpi-gpio-output': ['RPi.GPIO'], - 'rpi': ['RPi.GPIO'], - 'example-depends': ['requests', 'geocoder', 'beautifulsoup4', 'font-fredoka-one', 'font-source-serif-pro', 'font-hanken-grotesk', 'font-intuitive'] - } -) diff --git a/library/tests/test_init.py b/library/tests/test_init.py deleted file mode 100644 index b4ff8bf4..00000000 --- a/library/tests/test_init.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Initialization tests for Inky.""" - -from unittest import mock -import pytest - - -def test_init_mock_phat_black(tkinter, PIL): - """Test initialisation of InkyMockPHAT with 'black' colour choice.""" - from inky import InkyMockPHAT - - InkyMockPHAT('black') - - -def test_init_mock_what_black(tkinter, PIL): - """Test initialisation of InkyMockWHAT with 'black' colour choice.""" - from inky import InkyMockWHAT - - InkyMockWHAT('black') - - -def test_init_phat_black(spidev, smbus2): - """Test initialisation of InkyPHAT with 'black' colour choice.""" - from inky import InkyPHAT - - InkyPHAT('black') - - -def test_init_phat_red(spidev, smbus2): - """Test initialisation of InkyPHAT with 'red' colour choice.""" - from inky import InkyPHAT - - InkyPHAT('red') - - -def test_init_phat_yellow(spidev, smbus2): - """Test initialisation of InkyPHAT with 'yellow' colour choice.""" - from inky import InkyPHAT - - InkyPHAT('red') - - -def test_init_what_black(spidev, smbus2): - """Test initialisation of InkyWHAT with 'black' colour choice.""" - from inky import InkyWHAT - - InkyWHAT('black') - - -def test_init_what_red(spidev, smbus2): - """Test initialisation of InkyWHAT with 'red' colour choice.""" - from inky import InkyWHAT - - InkyWHAT('red') - - -def test_init_what_yellow(spidev, smbus2): - """Test initialisation of InkyWHAT with 'yellow' colour choice.""" - from inky import InkyWHAT - - InkyWHAT('yellow') - - -def test_init_invalid_colour(spidev, smbus2): - """Test initialisation of InkyWHAT with an invalid colour choice.""" - from inky import InkyWHAT - - with pytest.raises(ValueError): - InkyWHAT('octarine') - - -def test_init_what_setup_no_gpio(spidev, smbus2): - """Test Inky init with a missing RPi.GPIO library.""" - from inky import InkyWHAT - - inky = InkyWHAT('red') - - with pytest.raises(ImportError): - inky.setup() - - -def test_init_what_setup(spidev, smbus2, GPIO): - """Test initialisation and setup of InkyWHAT. - - Verify our expectations for GPIO setup in order to catch regressions. - - """ - from inky import InkyWHAT - - # TODO: _busy_wait should timeout after N seconds - GPIO.input.return_value = GPIO.LOW - - inky = InkyWHAT('red') - inky.setup() - - # Check GPIO setup - GPIO.setwarnings.assert_called_with(False) - GPIO.setmode.assert_called_with(GPIO.BCM) - GPIO.setup.assert_has_calls([ - mock.call(inky.dc_pin, GPIO.OUT, initial=GPIO.LOW, pull_up_down=GPIO.PUD_OFF), - mock.call(inky.reset_pin, GPIO.OUT, initial=GPIO.HIGH, pull_up_down=GPIO.PUD_OFF), - mock.call(inky.busy_pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - ]) - - # Check device will been reset - GPIO.output.assert_has_calls([ - mock.call(inky.reset_pin, GPIO.LOW), - mock.call(inky.reset_pin, GPIO.HIGH) - ]) - - # Check API will been opened - spidev.SpiDev().open.assert_called_with(0, inky.cs_channel) - - -def test_init_7colour_setup_no_gpio(spidev, smbus2): - """Test initialisation and setup of 7-colour Inky. - - Verify an error is raised when RPi.GPIO is not present. - - """ - from inky.inky_uc8159 import Inky - - inky = Inky() - - with pytest.raises(ImportError): - inky.setup() - - -def test_init_7colour_setup(spidev, smbus2, GPIO): - """Test initialisation and setup of 7-colour Inky. - - Verify our expectations for GPIO setup in order to catch regressions. - - """ - from inky.inky_uc8159 import Inky - - # TODO: _busy_wait should timeout after N seconds - GPIO.input.return_value = GPIO.LOW - - inky = Inky() - inky.setup() - - # Check GPIO setup - GPIO.setwarnings.assert_called_with(False) - GPIO.setmode.assert_called_with(GPIO.BCM) - GPIO.setup.assert_has_calls([ - mock.call(inky.dc_pin, GPIO.OUT, initial=GPIO.LOW, pull_up_down=GPIO.PUD_OFF), - mock.call(inky.reset_pin, GPIO.OUT, initial=GPIO.HIGH, pull_up_down=GPIO.PUD_OFF), - mock.call(inky.busy_pin, GPIO.IN, pull_up_down=GPIO.PUD_OFF) - ]) - - # Check device will been reset - GPIO.output.assert_has_calls([ - mock.call(inky.reset_pin, GPIO.LOW), - mock.call(inky.reset_pin, GPIO.HIGH) - ]) - - # Check API will been opened - spidev.SpiDev().open.assert_called_with(0, inky.cs_channel) diff --git a/library/tox.ini b/library/tox.ini deleted file mode 100644 index 4e838450..00000000 --- a/library/tox.ini +++ /dev/null @@ -1,26 +0,0 @@ -[tox] -envlist = py{39},qa -skip_missing_interpreters = True - -[testenv] -commands = - python setup.py develop --no-deps - coverage run -m pytest -v -r wsx - coverage report -m -deps = - mock - pytest>=3.1 - pytest-cov - -[testenv:qa] -commands = - check-manifest --ignore tox.ini,tests/*,.coveragerc - flake8 --ignore E501,E122,E241,F401,W504,Q000 - python setup.py sdist bdist_wheel - twine check dist/* -deps = - check-manifest - flake8 - flake8-docstrings - flake8-quotes - twine diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..5e92f538 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,132 @@ +[build-system] +requires = ["hatchling", "hatch-fancy-pypi-readme", "hatch-requirements-txt"] +build-backend = "hatchling.build" + +[project] +name = "inky" +dynamic = ["version", "readme", "optional-dependencies"] +description = "Inky pHAT Driver" +license = {file = "LICENSE"} +requires-python = ">= 3.7" +authors = [ + { name = "Philip Howard", email = "phil@pimoroni.com" }, +] +maintainers = [ + { name = "Philip Howard", email = "phil@pimoroni.com" }, +] +keywords = [ + "Raspberry Pi", + "e-paper", +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries", + "Topic :: System :: Hardware", +] +dependencies = [ + "numpy", + "pillow", + "smbus2", + "spidev", + "gpiodevice>=0.0.3" +] + +[tool.hatch.metadata.hooks.requirements_txt.optional-dependencies] +example-depends = ["requirements-examples.txt"] + +[project.urls] +GitHub = "https://www.github.com/pimoroni/inky" +Homepage = "https://www.pimoroni.com" + +[tool.hatch.version] +path = "inky/__init__.py" + +[tool.hatch.build] +include = [ + "inky", + "README.md", + "CHANGELOG.md", + "LICENSE", + "requirements-examples.txt" +] + +[tool.hatch.build.targets.sdist] +include = [ + "*" +] +exclude = [ + ".*", + "dist" +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" +fragments = [ + { path = "README.md" }, + { text = "\n" }, + { path = "CHANGELOG.md" } +] + +[tool.ruff] +exclude = [ + '.tox', + '.egg', + '.git', + '__pycache__', + 'build', + 'dist' +] +line-length = 200 + +[tool.codespell] +skip = """ +./.tox,\ +./.egg,\ +./.git,\ +./__pycache__,\ +./build,\ +./dist.\ +""" + +[tool.isort] +line_length = 200 + +[tool.black] +line-length = 200 + +[tool.check-manifest] +ignore = [ + '.stickler.yml', + 'boilerplate.md', + 'check.sh', + 'install.sh', + 'uninstall.sh', + 'Makefile', + 'tox.ini', + 'tests/*', + 'examples/*', + '.coveragerc', + 'requirements-dev.txt' +] + +[tool.pimoroni] +apt_packages = [] +configtxt = [ + "dtoverlay=i2c1", + "dtoverlay=i2c1-pi5", + "dtoverlay=spi0-0cs" +] +commands = [ + "sudo raspi-config nonint do_spi 0" +] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..d392e8fe --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +check-manifest +ruff +codespell +isort +twine +hatch +hatch-fancy-pypi-readme +hatch-requirements-txt +tox +pdoc diff --git a/requirements-examples.txt b/requirements-examples.txt new file mode 100644 index 00000000..f711a6f1 --- /dev/null +++ b/requirements-examples.txt @@ -0,0 +1,12 @@ +pillow +requests +beautifulsoup4 +fonts +font-source-sans-pro +font-source-serif-pro +font-fredoka-one +font-hanken-grotesk +font-intuitive +geocoder +seaborn +wikiquotes \ No newline at end of file diff --git a/sphinx/_static/custom.css b/sphinx/_static/custom.css deleted file mode 100644 index 141c20cd..00000000 --- a/sphinx/_static/custom.css +++ /dev/null @@ -1,53 +0,0 @@ -.rst-content a, .rst-content a:focus { - color:#13c0d7; -} -.rst-content a:visited, .rst-content a:active { - color:#87319a; -} -.rst-content .highlighted { - background:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAJElEQVQIW2P8//9/PSMjYyMDEmAEsdElwILoEnBBZAkUQZgEABMWE4Kzp1KUAAAAAElFTkSuQmCC),rgba(246,167,4,0.2); - margin:0 -6px; -} -.wy-side-nav-search { - background:#333333; -} -.wy-nav-side { - background:#444444; -} -.wy-menu-vertical a { - color:#cccccc -} -.wy-menu-vertical p.caption { - background: #333333; - color: #6d6d6d; -} -.rst-content dl:not(.docutils) dt { - background:#e7fafd; - border-top:solid 3px #13c0d7; - color:rgba(0,0,0,0.5); -} -.rst-content .viewcode-link, .rst-content .viewcode-back { - color:#00b09b; -} -code.literal { - color:#e63c2e; -} - - -.rst-content #at-a-glance { - margin-bottom:24px; -} -.rst-content #at-a-glance blockquote { - margin-left:0; -} -.rst-content #at-a-glance dl:not(.docutils) dt { - border:none; - background:#f0f0f0; -} -.rst-content #at-a-glance dl:not(.docutils) dd, -.rst-content #at-a-glance dl:not(.docutils) dd dl:not(.docutils) dd { - display:none; -} -.rst-content #at-a-glance dl:not(.docutils) { - margin-bottom:0; -} diff --git a/sphinx/_templates/breadcrumbs.html b/sphinx/_templates/breadcrumbs.html deleted file mode 100644 index e69de29b..00000000 diff --git a/sphinx/_templates/layout.html b/sphinx/_templates/layout.html deleted file mode 100644 index a2bd1c5c..00000000 --- a/sphinx/_templates/layout.html +++ /dev/null @@ -1,43 +0,0 @@ -{% extends "!layout.html" %} -{% block extrahead %} - -{% endblock %} -{% block footer %} - -{% endblock %} \ No newline at end of file diff --git a/sphinx/conf.py b/sphinx/conf.py deleted file mode 100644 index a4877486..00000000 --- a/sphinx/conf.py +++ /dev/null @@ -1,365 +0,0 @@ -#-*- coding: utf-8 -*- - -import sys -import site - -from unittest import mock -PACKAGE_NAME = u"Inky" -PACKAGE_HANDLE = "Inky" -PACKAGE_MODULE = "inky" - -# Prompte /usr/local/lib to the front of sys.path -#sys.path.insert(0,site.getsitepackages()[0]) - -import sphinx_rtd_theme - -MOCK_MODULES = ['RPi', 'RPi.GPIO', 'smbus2', 'smbus', 'numpy', 'spidev', 'PIL'] -for module_name in MOCK_MODULES: - sys.modules[module_name] = mock.MagicMock() - -sys.path.insert(0, '../library/') - - -import inky - -from sphinx.ext import autodoc - - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.viewcode', - 'sphinx.ext.intersphinx', - 'sphinx.ext.autosummary' -] - -autoclass_content = 'both' - -# Intersphinx configuration -intersphinx_mapping = { - 'numpy': ('https://docs.scipy.org/doc/numpy/', None), - 'PIL': ('https://pillow.readthedocs.io/en/stable/', None), - 'python': ('https://docs.python.org/3', None), - 'smbus2': ('https://smbus2.readthedocs.io/en/latest/', None), - } - -# Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = '.rst' - -# The encoding of source files. -# -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = 'index' - -# General information about the project. -project = PACKAGE_NAME -copyright = u'2019, Pimoroni Ltd' -author = u'Phil Howard' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = u'{}'.format(inky.__version__) -# The full version, including alpha/beta/rc tags. -release = u'{}'.format(inky.__version__) - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = None - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# -# today = '' -# -# Else, today_fmt is used as the format for a strftime call. -# -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'sphinx.virtualenv'] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = 'sphinx_rtd_theme' -#html_theme = 'alabaster' - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -html_theme_options = { - 'collapse_navigation': False, - 'display_version': True -} - -# Add any paths that contain custom themes here, relative to this directory. -html_theme_path = [ - '_themes', - sphinx_rtd_theme.get_html_theme_path() -] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -# -# html_title = PACKAGE_NAME + u' v0.1.2' - -# A shorter title for the navigation bar. Default is the same as html_title. -# -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# -html_logo = 'shop-logo.png' - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# -html_favicon = 'favicon.png' - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# -# html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -# -# html_last_updated_fmt = None - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# -# html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# -# html_sidebars = {} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# -# html_additional_pages = {} - -# If false, no module index is generated. -# -# html_domain_indices = True - -# If false, no index is generated. -# -html_use_index = False - -# If true, the index is split into individual pages for each letter. -# -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# -html_show_sourcelink = False - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# -html_show_sphinx = False - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' -# -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -# -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = PACKAGE_HANDLE + 'doc' - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - (master_doc, PACKAGE_HANDLE + '.tex', PACKAGE_NAME + u' Documentation', - u'Phil Howard', 'manual'), -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, PACKAGE_MODULE, PACKAGE_NAME + u' Documentation', - [author], 1) -] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - (master_doc, PACKAGE_HANDLE, PACKAGE_NAME + u' Documentation', - author, PACKAGE_HANDLE, 'One line description of project.', - 'Miscellaneous'), -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False diff --git a/sphinx/favicon.png b/sphinx/favicon.png deleted file mode 100644 index 5ed0316c..00000000 Binary files a/sphinx/favicon.png and /dev/null differ diff --git a/sphinx/index.rst b/sphinx/index.rst deleted file mode 100644 index 7e0c786a..00000000 --- a/sphinx/index.rst +++ /dev/null @@ -1,25 +0,0 @@ -.. role:: python(code) - :language: python - -Welcome -------- - -This documentation will guide you through the methods available in the Inky python library. - -The Pimoroni website has tutorials demonstrating how to set up and use the `Inky pHAT`_ and `Inky wHAT`_. - -Further examples can be found on the `Inky Git repository`_. - -.. _`Inky pHAT`: https://learn.pimoroni.com/tutorial/sandyj/getting-started-with-inky-phat -.. _`Inky wHAT`: https://learn.pimoroni.com/tutorial/sandyj/getting-started-with-inky-what -.. _`Inky Git repository`: https://github.com/pimoroni/inky/tree/master/examples - -.. currentmodule:: inky - -.. toctree:: - :maxdepth: 2 - :caption: Contents: - - phat - what - inky diff --git a/sphinx/inky.rst b/sphinx/inky.rst deleted file mode 100644 index b07f07af..00000000 --- a/sphinx/inky.rst +++ /dev/null @@ -1,5 +0,0 @@ -Inky Base Class ---------------- - -.. autoclass:: inky.inky.Inky - :members: diff --git a/sphinx/phat.rst b/sphinx/phat.rst deleted file mode 100644 index 22a31f95..00000000 --- a/sphinx/phat.rst +++ /dev/null @@ -1,8 +0,0 @@ -Inky pHAT ---------- - -.. automodule:: inky.phat - -.. autoclass:: inky.InkyPHAT - :members: - :inherited-members: diff --git a/sphinx/requirements.txt b/sphinx/requirements.txt deleted file mode 100644 index fb969a5a..00000000 --- a/sphinx/requirements.txt +++ /dev/null @@ -1,30 +0,0 @@ -alabaster==0.7.12 -Babel==2.9.1 -certifi==2021.5.30 -chardet==4.0.0 -charset-normalizer==2.0.3 -docutils==0.16 -funcsigs==1.0.2 -idna==3.2 -imagesize==1.2.0 -Jinja2==3.0.1 -MarkupSafe==2.0.1 -packaging==21.0 -Pillow==8.3.1 -Pygments==2.9.0 -pyparsing==2.4.7 -pytz==2021.1 -requests==2.26.0 -six==1.16.0 -snowballstemmer==2.1.0 -Sphinx==4.1.2 -sphinx-rtd-theme==0.5.2 -sphinxcontrib-applehelp==1.0.2 -sphinxcontrib-devhelp==1.0.2 -sphinxcontrib-htmlhelp==2.0.0 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==1.0.3 -sphinxcontrib-serializinghtml==1.1.5 -sphinxcontrib-websupport==1.2.4 -typing==3.7.4.3 -urllib3==1.26.6 diff --git a/sphinx/shop-logo.png b/sphinx/shop-logo.png deleted file mode 100644 index 8fd0cda2..00000000 Binary files a/sphinx/shop-logo.png and /dev/null differ diff --git a/sphinx/what.rst b/sphinx/what.rst deleted file mode 100644 index 69645c7c..00000000 --- a/sphinx/what.rst +++ /dev/null @@ -1,8 +0,0 @@ -Inky wHAT ---------- - -.. automodule:: inky.what - -.. autoclass:: inky.InkyWHAT - :members: - :inherited-members: diff --git a/library/tests/conftest.py b/tests/conftest.py similarity index 59% rename from library/tests/conftest.py rename to tests/conftest.py index 8357ee90..607de452 100644 --- a/library/tests/conftest.py +++ b/tests/conftest.py @@ -6,22 +6,41 @@ """ import sys from unittest import mock + import pytest from tools import MockSMBus +@pytest.fixture(scope='function', autouse=True) +def cleanup(): + for module in list(sys.modules.keys()): + if module.startswith('inky'): + del sys.modules[module] + + +@pytest.fixture(scope='function', autouse=False) +def nopath(): + old_path = sys.path + sys.path = [path for path in sys.path if not path.startswith("/usr/lib") and not path.startswith("/opt/hostedtoolcache")] + yield + sys.path = old_path + + @pytest.fixture(scope='function', autouse=False) def GPIO(): - """Mock RPi.GPIO module.""" - GPIO = mock.MagicMock() - # Fudge for Python < 37 (possibly earlier) - sys.modules['RPi'] = mock.MagicMock() - sys.modules['RPi'].GPIO = GPIO - sys.modules['RPi.GPIO'] = GPIO - yield GPIO - del sys.modules['RPi'] - del sys.modules['RPi.GPIO'] + """Mock gpiod and gpiodevice modules.""" + gpiod = mock.MagicMock() + gpiodevice = mock.MagicMock() + sys.modules['gpiod'] = gpiod + sys.modules['gpiod.line'] = gpiod + sys.modules['gpiodevice'] = gpiodevice + sys.modules['gpiodevice.platform'] = mock.MagicMock() + yield gpiod, gpiodevice + del sys.modules['gpiod'] + del sys.modules['gpiod.line'] + del sys.modules['gpiodevice'] + del sys.modules['gpiodevice.platform'] @pytest.fixture(scope='function', autouse=False) diff --git a/library/tests/test_auto.py b/tests/test_auto.py similarity index 88% rename from library/tests/test_auto.py rename to tests/test_auto.py index 8ee53364..ae23289a 100644 --- a/library/tests/test_auto.py +++ b/tests/test_auto.py @@ -1,7 +1,7 @@ """Auto-detect tests for Inky.""" -import pytest import sys +import pytest DISPLAY_VARIANT = [ None, @@ -30,10 +30,9 @@ @pytest.mark.parametrize('verbose', [True, False]) @pytest.mark.parametrize('inky_colour', ['black', 'red', 'yellow', None]) @pytest.mark.parametrize('inky_type', ['phat', 'what', 'phatssd1608', 'impressions', '7colour', 'whatssd1683']) -def test_auto_fallback(spidev, smbus2, PIL, inky_type, inky_colour, verbose): +def test_auto_fallback(GPIO, spidev, smbus2, PIL, inky_type, inky_colour, verbose): """Test auto init of 'phat', 'black'.""" - from inky import InkyPHAT, InkyPHAT_SSD1608, InkyWHAT, Inky7Colour, InkyWHAT_SSD1683 - from inky import auto + from inky import Inky7Colour, InkyPHAT, InkyPHAT_SSD1608, InkyWHAT, InkyWHAT_SSD1683, auto if inky_type in ['impressions', '7colour']: if inky_colour is not None: @@ -62,11 +61,9 @@ def test_auto_fallback(spidev, smbus2, PIL, inky_type, inky_colour, verbose): @pytest.mark.parametrize('inky_display', enumerate(DISPLAY_VARIANT)) -def test_auto(spidev, smbus2_eeprom, PIL, inky_display): +def test_auto(GPIO, spidev, smbus2_eeprom, PIL, inky_display): """Test auto init of 'phat', 'black'.""" - from inky import InkyPHAT, InkyPHAT_SSD1608, InkyWHAT, Inky7Colour, InkyWHAT_SSD1683 - from inky import auto - from inky import eeprom + from inky import Inky7Colour, InkyPHAT, InkyPHAT_SSD1608, InkyWHAT, InkyWHAT_SSD1683, auto, eeprom display_id, display_name = inky_display diff --git a/library/tests/test_eeprom.py b/tests/test_eeprom.py similarity index 83% rename from library/tests/test_eeprom.py rename to tests/test_eeprom.py index c310848f..047c56a6 100644 --- a/library/tests/test_eeprom.py +++ b/tests/test_eeprom.py @@ -1,10 +1,10 @@ """EEPROM tests for Inky.""" -def test_eeprom_7color_5_7_inch(spidev, smbus2_eeprom, PIL): +def test_eeprom_7color_5_7_inch(GPIO, spidev, smbus2_eeprom, PIL): """Test EEPROM for 7color 5.7" Inky.""" - from inky.inky_uc8159 import Inky from inky.eeprom import EPDType + from inky.inky_uc8159 import Inky eeprom_data = EPDType(600, 448, 0, 0, 14).encode() @@ -15,10 +15,10 @@ def test_eeprom_7color_5_7_inch(spidev, smbus2_eeprom, PIL): assert inky.resolution == (600, 448) -def test_eeprom_7color_4_inch(spidev, smbus2_eeprom, PIL): +def test_eeprom_7color_4_inch(GPIO, spidev, smbus2_eeprom, PIL): """Test EEPROM for 7color 4" Inky.""" - from inky.inky_uc8159 import Inky from inky.eeprom import EPDType + from inky.inky_uc8159 import Inky eeprom_data = EPDType(640, 400, 0, 0, 16).encode() diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 00000000..6e2b6557 --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,103 @@ +"""Initialization tests for Inky.""" + +import pytest + + +def test_init_mock_phat_black(GPIO, tkinter, PIL): + """Test initialisation of InkyMockPHAT with 'black' colour choice.""" + from inky import InkyMockPHAT + + InkyMockPHAT('black') + + +def test_init_mock_what_black(GPIO, tkinter, PIL): + """Test initialisation of InkyMockWHAT with 'black' colour choice.""" + from inky import InkyMockWHAT + + InkyMockWHAT('black') + + +def test_init_phat_black(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyPHAT with 'black' colour choice.""" + from inky import InkyPHAT + + InkyPHAT('black') + + +def test_init_phat_red(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyPHAT with 'red' colour choice.""" + from inky import InkyPHAT + + InkyPHAT('red') + + +def test_init_phat_yellow(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyPHAT with 'yellow' colour choice.""" + from inky import InkyPHAT + + InkyPHAT('red') + + +def test_init_what_black(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyWHAT with 'black' colour choice.""" + from inky import InkyWHAT + + InkyWHAT('black') + + +def test_init_what_red(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyWHAT with 'red' colour choice.""" + from inky import InkyWHAT + + InkyWHAT('red') + + +def test_init_what_yellow(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyWHAT with 'yellow' colour choice.""" + from inky import InkyWHAT + + InkyWHAT('yellow') + + +def test_init_invalid_colour(GPIO, spidev, smbus2, PIL): + """Test initialisation of InkyWHAT with an invalid colour choice.""" + from inky import InkyWHAT + + with pytest.raises(ValueError): + InkyWHAT('octarine') + + +def test_init_what_setup(GPIO, spidev, smbus2, PIL): + """Test initialisation and setup of InkyWHAT. + + Verify our expectations for GPIO setup in order to catch regressions. + + """ + from inky import InkyWHAT + + # _busy_wait will timeout after N seconds + # GPIO.input.return_value = GPIO.LOW + + inky = InkyWHAT('red') + inky.setup() + + # Check API will been opened + spidev.SpiDev().open.assert_called_with(0, inky.cs_channel) + + +def test_init_7colour_setup(GPIO, spidev, smbus2, PIL): + """Test initialisation and setup of 7-colour Inky. + + Verify our expectations for GPIO setup in order to catch regressions. + + """ + from inky.inky_uc8159 import Inky + + # _busy_wait will timeout after N seconds + # GPIO.input.return_value = GPIO.LOW + + inky = Inky() + inky.setup() + + # Check API will been opened + spidev.SpiDev().open.assert_called_with(0, inky.cs_channel) diff --git a/library/tests/test_install_helpers.py b/tests/test_install_helpers.py similarity index 53% rename from library/tests/test_install_helpers.py rename to tests/test_install_helpers.py index 55c20395..e90e0cbe 100644 --- a/library/tests/test_install_helpers.py +++ b/tests/test_install_helpers.py @@ -8,7 +8,7 @@ import pytest -def test_mock_phat_no_tkinter(): +def test_mock_phat_no_tkinter(PIL, nopath): """Test initialisation of InkyMockPHAT without tkinter.""" from inky import InkyMockPHAT @@ -16,25 +16,10 @@ def test_mock_phat_no_tkinter(): InkyMockPHAT('black') -def test_mock_what_no_tkinter(): +def test_mock_what_no_tkinter(PIL, nopath): """Test initialisation of InkyMockWHAT without tkinter.""" from inky import InkyMockWHAT with pytest.raises(ImportError): InkyMockWHAT('black') - -def test_mock_phat_no_pil(tkinter): - """Test initialisation of InkyMockPHAT without PIL.""" - from inky import InkyMockPHAT - - with pytest.raises(ImportError): - InkyMockPHAT('black') - - -def test_mock_what_no_pil(tkinter): - """Test initialisation of InkyMockWHAT without PIL.""" - from inky import InkyMockWHAT - - with pytest.raises(ImportError): - InkyMockWHAT('black') diff --git a/tests/test_set_image.py b/tests/test_set_image.py new file mode 100644 index 00000000..61bbf657 --- /dev/null +++ b/tests/test_set_image.py @@ -0,0 +1,28 @@ +"""Set image tests for Inky.""" +import pytest + + +@pytest.mark.parametrize('resolution', [(800, 480), (600, 448), (400, 300), (212, 104), (250, 122)]) +def test_inky_set_image(GPIO, spidev, smbus2, resolution): + from PIL import Image + + from inky.inky import Inky + + phat = Inky(resolution) + + width, height = phat.resolution + + image = Image.new("P", (width, height)) + + for x in range(width): + image.putpixel((x, 0), x % 3) + + assert image.size == (width, height) + + phat.set_image(image) + phat.set_pixel(0, 0, 2) + + data = [x % 3 for x in range(width)] + data[0] = 2 + + assert phat.buf.flatten().tolist()[0:width] == data diff --git a/library/tests/test_simulator.py b/tests/test_simulator.py similarity index 100% rename from library/tests/test_simulator.py rename to tests/test_simulator.py diff --git a/library/tests/tools.py b/tests/tools.py similarity index 100% rename from library/tests/tools.py rename to tests/tools.py diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..2b6d87b8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,27 @@ +[tox] +envlist = py,qa +skip_missing_interpreters = True +isolated_build = true +minversion = 4.0.0 + +[testenv] +commands = + coverage run -m pytest -v -r wsx + coverage report +deps = + mock + pytest>=3.1 + pytest-cov + build + +[testenv:qa] +commands = + check-manifest + python -m build --no-isolation + python -m twine check dist/* + isort --check . + ruff check . + codespell . +deps = + -r{toxinidir}/requirements-dev.txt + diff --git a/uninstall.sh b/uninstall.sh index 0928b014..3314b7fc 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -1,24 +1,72 @@ #!/bin/bash -PACKAGE="inky" +FORCE=false +LIBRARY_NAME=$(grep -m 1 name pyproject.toml | awk -F" = " '{print substr($2,2,length($2)-2)}') +RESOURCES_DIR=$HOME/Pimoroni/$LIBRARY_NAME +PYTHON="python" -printf "Inky Python Library: Uninstaller\n\n" -if [ $(id -u) -ne 0 ]; then - printf "Script must be run as root. Try 'sudo ./uninstall.sh'\n" - exit 1 -fi +venv_check() { + PYTHON_BIN=$(which $PYTHON) + if [[ $VIRTUAL_ENV == "" ]] || [[ $PYTHON_BIN != $VIRTUAL_ENV* ]]; then + printf "This script should be run in a virtual Python environment.\n" + exit 1 + fi +} -cd library +user_check() { + if [ "$(id -u)" -eq 0 ]; then + printf "Script should not be run as root. Try './uninstall.sh'\n" + exit 1 + fi +} -printf "Unnstalling for Python 2..\n" -pip uninstall $PACKAGE +confirm() { + if $FORCE; then + true + else + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi + fi +} -if [ -f "/usr/bin/pip3" ]; then - printf "Uninstalling for Python 3..\n" - pip3 uninstall $PACKAGE -fi +prompt() { + read -r -p "$1 [y/N] " response < /dev/tty + if [[ $response =~ ^(yes|y|Y)$ ]]; then + true + else + false + fi +} + +success() { + echo -e "$(tput setaf 2)$1$(tput sgr0)" +} + +inform() { + echo -e "$(tput setaf 6)$1$(tput sgr0)" +} + +warning() { + echo -e "$(tput setaf 1)$1$(tput sgr0)" +} -cd .. +printf "%s Python Library: Uninstaller\n\n" "$LIBRARY_NAME" + +user_check +venv_check + +printf "Uninstalling for Python 3...\n" +$PYTHON -m pip uninstall "$LIBRARY_NAME" + +if [ -d "$RESOURCES_DIR" ]; then + if confirm "Would you like to delete $RESOURCES_DIR?"; then + rm -r "$RESOURCES_DIR" + fi +fi printf "Done!\n"