-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit b78c834
Showing
10 changed files
with
384 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
name: Validate | ||
|
||
on: [push] | ||
|
||
jobs: | ||
validate: | ||
|
||
runs-on: ubuntu-latest | ||
|
||
steps: | ||
- name: Checkout | ||
uses: actions/checkout@v4 | ||
|
||
# QEMU is needed to build for arm64 | ||
- name: Set up QEMU | ||
uses: docker/setup-qemu-action@v3 | ||
|
||
- name: Set up Docker Buildx | ||
uses: docker/setup-buildx-action@v3 | ||
|
||
- name: Set up Python | ||
uses: actions/setup-python@v5 | ||
with: | ||
python-version: '3.11' | ||
|
||
- name: Compile source | ||
run: python -m compileall src | ||
|
||
- name: Build container image | ||
uses: docker/build-push-action@v6 | ||
with: | ||
platforms: linux/arm64 | ||
push: false | ||
tags: user/app:latest |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
.idea | ||
**.pyc |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
FROM dtcooper/raspberrypi-os:bookworm | ||
|
||
EXPOSE 5000/tcp | ||
|
||
WORKDIR /app | ||
|
||
RUN apt update | ||
RUN apt install -y gcc libc-dev linux-headers | ||
|
||
# Install waveshare libs | ||
RUN apt install -y openssl wget unzip | ||
RUN wget https://github.com/waveshareteam/e-Paper/archive/refs/heads/master.zip | ||
RUN unzip master.zip | ||
RUN cp -r e-Paper-master/RaspberryPi_JetsonNano/python/lib lib | ||
RUN rm master.zip | ||
RUN rm -r e-Paper-master | ||
|
||
# Install python pip deps | ||
RUN apt install -y python3-pip python3-venv | ||
|
||
ENV VIRTUAL_ENV=/opt/venv | ||
RUN python3 -m venv $VIRTUAL_ENV | ||
ENV PATH="$VIRTUAL_ENV/bin:$PATH" | ||
|
||
COPY requirements.txt . | ||
RUN pip install -r requirements.txt | ||
|
||
RUN pip uninstall -y rpi.gpio | ||
RUN pip install rpi-lgpio | ||
|
||
# Setup app | ||
COPY src . | ||
|
||
CMD [ "flask", "--app", "server", "run", "--host=0.0.0.0" ] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
MIT License | ||
|
||
Copyright (c) 2024 Adrian Keenan | ||
|
||
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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
# rpi-epaper-api | ||
|
||
A rest API for setting the display image on a [Waveshare 4.26](https://www.waveshare.com/4.26inch-e-paper-hat.htm) | ||
eink HAT connected to a Raspberry Pi. Supports scaling and rotating the input image. Endpoints are also provided for | ||
fetching display contents and clearing the display. | ||
|
||
This project utilises the [Waveshare e-paper SDKs](https://github.com/waveshareteam/e-Paper) for handling the device commands. | ||
|
||
For convenience, this tool is packaged as a docker container. It can also be run on bare metal, but you will need to | ||
add the Waveshare SDK to your path (see [Dockerfile](./Dockerfile)). | ||
|
||
⚠️ The API is served via the Flask development webserver - you should not expose it publicly. | ||
|
||
## How to use | ||
|
||
### Install | ||
|
||
Build and run the container: | ||
|
||
```commandline | ||
git clone https://github.com/adriankeenan/rpi-epaper-api.git | ||
docker build -t rpi-eink-api . | ||
docker run -p 5000:5000 rpi-eink-api --restart=always --privileged | ||
``` | ||
|
||
Now you're ready to send images to the display! | ||
|
||
`--restart=always` will ensure that the container is restarted on crash and on system boot. | ||
|
||
### Setting the image | ||
|
||
`POST http://rpi:5000` | ||
|
||
```commandline | ||
curl --form [email protected] --form resize=FIT --form background=WHITE http://rpi:5000 | jq | ||
``` | ||
|
||
Data should be sent form-encoded. | ||
|
||
| Field | Description | Default | Required | | ||
|--------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------|----------| | ||
| `image` | Image file to display. Any image type supported by the Python PIL library should work. | No | Yes | | ||
| `resize` | `FIT` Resize keeping aspect ratio, without cropping<br>`CROP` Resize keeping aspect ratio, with cropping<br>`STRETCH` fill display, ignoring aspect ratio<br>`NONE` Display without any scaling, pixels drawn 1:1. | `FIT` | No | | ||
| `rotate` | Number of degrees to rotate counter-clockwise, supports increments of 90 degrees. | 0 | No | | ||
| `background` | Background colour to use if the image doesn't fill the display. Either `WHITE` or `BLACK`. | `WHITE` | No | | ||
|
||
Expect this call to take ~9 seconds. | ||
|
||
Display update will be skipped if the resulting image is the same as the last request (see `updated` field in the response). | ||
|
||
This endpoint only supports a single concurrent call in order to prevent simultaneous instructions being sent to the hardware. | ||
|
||
### Fetching the current image | ||
|
||
`GET http://rpi:5000` | ||
|
||
```commandline | ||
curl http://rpi:5000/img.png | ||
``` | ||
|
||
Returns the last image (as PNG) that was successfully sent to the display. This is the exact framebuffer sent to the device (eg post-scaling). | ||
This is useful for checking the scaling and rotation settings are correct. | ||
|
||
This image is not persisted between restarts of the container. | ||
|
||
### Clearing the current image | ||
|
||
`DELETE http://rpi:5000` | ||
|
||
```commandline | ||
curl -X DELETE http://rpi:5000 | ||
``` | ||
|
||
Expect this call to take ~7 seconds. | ||
|
||
Reverts all pixels to the off (white) state. | ||
|
||
## Notes | ||
|
||
- Although this is project is intended only for the 4.26" e-paper display, it's likely that other displays can be supported | ||
by changing the imported EDP object. | ||
- It should be possible to update only part of the display if you are sending images where only a small amount of content changes | ||
each time. Currently, only clearing and refreshing the entire display is supported. | ||
|
||
# License | ||
|
||
[MIT](./LICENSE) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
rpi.gpio | ||
spidev | ||
gpiozero | ||
Flask~=3.0.3 | ||
filelock~=3.15.4 | ||
pillow~=10.4.0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import logging | ||
from contextlib import contextmanager | ||
from sys import path | ||
|
||
from PIL import Image | ||
from flask import Response, jsonify | ||
from filelock import Timeout as FileLockTimeout, FileLock | ||
|
||
|
||
path.append('lib') | ||
# noinspection PyUnresolvedReferences | ||
from waveshare_epd import epd4in26 | ||
|
||
@contextmanager | ||
def get_epd_lock(): | ||
lock = FileLock('epd.lock', timeout=10) | ||
with lock.acquire(): | ||
yield epd4in26.EPD() | ||
|
||
def display_clear(): | ||
with get_epd_lock() as epd: | ||
epd.init() | ||
epd.Clear() | ||
epd.sleep() | ||
|
||
def display_img(image: Image): | ||
with get_epd_lock() as epd: | ||
epd.init() | ||
epd.Clear() | ||
epd.init_Fast() | ||
epd.display_Fast(epd.getbuffer(image)) | ||
epd.sleep() | ||
|
||
def handle_epd_error(e: Exception) -> tuple[Response, int]: | ||
if isinstance(e, IOError): | ||
logging.error(f'IOError on display write: {str(e)}') | ||
return jsonify(message='Unexpected error'), 500 | ||
elif isinstance(e, KeyboardInterrupt): | ||
logging.info('Display write interrupted due to KeyboardInterrupt') | ||
epd4in26.epdconfig.module_exit(cleanup=True) | ||
return jsonify(message='Cancelled'), 500 | ||
elif isinstance(e, FileLockTimeout): | ||
logging.info('Could not obtain device lock') | ||
return jsonify(message='Device busy, please wait for current request to complete'), 409 | ||
else: | ||
logging.error(f'Unexpected error occurred - {str(e)}') | ||
return jsonify(message='Unexpected error occurred'), 500 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
from typing import Tuple | ||
from PIL import Image | ||
|
||
from models import Resolution, Rotation, Resize, BackgroundColour | ||
|
||
def resize_img(img: Image, rotation: Rotation, resize: Resize, background: BackgroundColour, display_res: Resolution) -> Image: | ||
# Rotate | ||
out_img = img.rotate(angle=rotation, expand=True) | ||
|
||
# Scale image | ||
if resize == Resize.FIT: | ||
scaled_resolution = get_resize_scale(out_img, False, display_res) | ||
elif resize == Resize.CROP: | ||
scaled_resolution = get_resize_scale(out_img, True, display_res) | ||
elif resize == Resize.NONE: | ||
scaled_resolution = (img.width, img.height) | ||
else: | ||
scaled_resolution = display_res | ||
|
||
scaled_image = out_img.resize(scaled_resolution) | ||
|
||
# Add scaled image to full size canvas | ||
bg_colour = 255 if background == BackgroundColour.WHITE else 0 | ||
x = int((display_res.width - scaled_image.width) / 2) | ||
y = int((display_res.height - scaled_image.height) / 2) | ||
canvas = Image.new('1', display_res, bg_colour) | ||
canvas.paste(scaled_image, (x, y)) | ||
return canvas | ||
|
||
def get_resize_scale(img: Image, crop: bool, display_res: Resolution) -> Tuple[int, int]: | ||
width_scale = display_res.width / img.width | ||
height_scale = display_res.height / img.height | ||
scale = max(width_scale, height_scale) if crop else min(width_scale, height_scale) | ||
return int(img.width * scale), int(img.height * scale) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
from collections import namedtuple | ||
from enum import StrEnum, IntEnum | ||
|
||
Resolution = namedtuple("Resolution", "width height") | ||
|
||
class Rotation(IntEnum): | ||
ROTATE_0 = 0 | ||
ROTATE_90 = 90 | ||
ROTATE_180 = 180 | ||
ROTATE_270 = 270 | ||
|
||
class Resize(StrEnum): | ||
FIT = 'FIT' | ||
STRETCH = 'STRETCH' | ||
CROP = 'CROP' | ||
NONE = 'NONE' | ||
|
||
class BackgroundColour(StrEnum): | ||
BLACK = 'BLACK' | ||
WHITE = 'WHITE' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
from pathlib import Path | ||
|
||
from flask import Flask, request, jsonify, send_file, Response | ||
from PIL import Image, UnidentifiedImageError | ||
|
||
import logging | ||
|
||
from models import Resolution, Rotation, Resize, BackgroundColour | ||
|
||
from img_utils import resize_img | ||
from epd_utils import get_epd_lock, handle_epd_error, display_clear, display_img | ||
|
||
IMG_PATH = 'img.png' | ||
DISPLAY_RESOLUTION = Resolution(800, 480) | ||
|
||
def get_last_set_image() -> Response | tuple[Response, int]: | ||
|
||
if not Path(IMG_PATH).exists(): | ||
logging.error(f'Existing image file doesn\'t exist') | ||
return jsonify(message='Framebuffer image not found'), 404 | ||
|
||
try: | ||
return send_file(IMG_PATH) | ||
except Exception as e: | ||
logging.error(f'Unable to load last set image - {str(e)}') | ||
return jsonify(message='Unknown error fetching file'), 500 | ||
|
||
def image_changed(new_image: Image) -> bool: | ||
try: | ||
existing_image = Image.open(IMG_PATH) | ||
except FileNotFoundError: | ||
return True | ||
except Exception as e: | ||
logging.error(e) | ||
return True | ||
|
||
return list(new_image.getdata()) != list(existing_image.getdata()) | ||
|
||
def show_image() -> tuple[Response, int]: | ||
|
||
try: | ||
img_file = request.files['image'].stream | ||
image = Image.open(img_file) | ||
except UnidentifiedImageError: | ||
return jsonify(message='"image" does not appear to be valid'), 422 | ||
except Exception as e: | ||
logging.error(f'Unable to read image - {str(e)}') | ||
return jsonify(message=f'Unable to read image'), 422 | ||
|
||
try: | ||
rotate = Rotation(int(request.form.get('rotate', Rotation.ROTATE_0.value))) | ||
except ValueError: | ||
return jsonify(message=f'"rotation" invalid, must be one of {", ".join([str(x.value) for x in Rotation])}'), 422 | ||
|
||
try: | ||
resize = Resize(request.form.get('resize', Resize.FIT.value)) | ||
except ValueError: | ||
return jsonify(message=f'"resize" invalid, must be one of {", ".join([x for x in Resize])}'), 422 | ||
|
||
try: | ||
background = BackgroundColour(request.form.get('background', BackgroundColour.WHITE.value)) | ||
except ValueError: | ||
return jsonify(message=f'"background" invalid, must be one of {", ".join([x for x in BackgroundColour])}'), 422 | ||
|
||
image_to_display = resize_img(image, rotate, resize, background, DISPLAY_RESOLUTION) | ||
|
||
update_image = image_changed(image_to_display) | ||
if update_image: | ||
try: | ||
display_img(image_to_display) | ||
image_to_display.save(IMG_PATH) | ||
except Exception as e: | ||
return handle_epd_error(e) | ||
|
||
return jsonify(message='Success', updated=update_image), 200 | ||
|
||
def clear_image(): | ||
try: | ||
display_clear() | ||
Image.new('1', DISPLAY_RESOLUTION, 255).save(IMG_PATH) | ||
return jsonify(message='Success'), 200 | ||
except Exception as e: | ||
return handle_epd_error(e) | ||
|
||
|
||
logging.basicConfig(level=logging.DEBUG) | ||
|
||
app = Flask(__name__) | ||
|
||
@app.route("/", methods=['GET', 'POST', 'DELETE']) | ||
def img(): | ||
if request.method == 'GET': | ||
return get_last_set_image() | ||
|
||
if request.method == 'POST': | ||
return show_image() | ||
|
||
if request.method == 'DELETE': | ||
return clear_image() |