Skip to content

Commit

Permalink
🚀
Browse files Browse the repository at this point in the history
  • Loading branch information
adriankeenan committed Sep 2, 2024
0 parents commit b78c834
Show file tree
Hide file tree
Showing 10 changed files with 384 additions and 0 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/validate.yaml
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.idea
**.pyc
34 changes: 34 additions & 0 deletions Dockerfile
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" ]
21 changes: 21 additions & 0 deletions LICENSE
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.
87 changes: 87 additions & 0 deletions readme.md
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)
6 changes: 6 additions & 0 deletions requirements.txt
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
47 changes: 47 additions & 0 deletions src/epd_utils.py
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
34 changes: 34 additions & 0 deletions src/img_utils.py
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)
20 changes: 20 additions & 0 deletions src/models.py
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'
99 changes: 99 additions & 0 deletions src/server.py
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()

0 comments on commit b78c834

Please sign in to comment.