Skip to content

Instantly share code, notes, and snippets.

@zitterbewegung
Last active August 23, 2025 16:45
Show Gist options
  • Save zitterbewegung/ccdbf8ac35c00bbab06b64ced6bb7ff7 to your computer and use it in GitHub Desktop.
Save zitterbewegung/ccdbf8ac35c00bbab06b64ced6bb7ff7 to your computer and use it in GitHub Desktop.
Psychic Paper Quickstart.
# 1) Copy your files to the Pi (adjust path/host as needed)
# Put setup-inky-app.sh, main.py, requirements-2.txt in the same folder.
# From your local machine:
# scp setup-inky-app.sh main.py requirements-2.txt [email protected]:/home/pi/
# 2) SSH into the Pi:
# ssh [email protected]
# 3) Run the setup (as root):
sudo bash ./setup-inky-app.sh
# 4) (Optional) Reboot to apply SPI/I2C group membership:
sudo reboot
# Useful service commands
# Check logs
journalctl -u inky-app.service -e -f
# Restart after editing main.py
sudo systemctl restart inky-app.service
# Stop / Disable
sudo systemctl stop inky-app.service
sudo systemctl disable inky-app.service
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from PIL import Image, ExifTags
from io import BytesIO
import inky
import time
# Initialize FastAPI
app = FastAPI()
# Allow CORS for all origins (adjust as needed for security)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Initialize the Inky ePaper display (adjust this initialization according to your specific seven-color display)
display = inky.auto() # Automatically detect your Inky display type and version
def process_image(image: Image.Image):
"""Process the image to correct orientation and resize it for the ePaper screen."""
# Correct image orientation using EXIF data, if available
try:
for orientation in ExifTags.TAGS.keys():
if ExifTags.TAGS[orientation] == 'Orientation':
break
exif = image._getexif()
if exif is not None:
orientation = exif.get(orientation, None)
if orientation == 3:
image = image.rotate(180, expand=True)
elif orientation == 6:
image = image.rotate(270, expand=True)
elif orientation == 8:
image = image.rotate(90, expand=True)
except Exception as e:
print(f"EXIF orientation correction failed: {e}")
# Resize the image to match the display's resolution
image = image.resize(display.resolution, Image.ANTIALIAS)
return image
def display_image(image: Image.Image):
"""Function to display the image on the ePaper screen."""
try:
# Simulate some processing delay for visual feedback
time.sleep(2) # Optional: simulate processing delay for user feedback
display.set_image(image)
display.show()
print("Image successfully displayed on the ePaper screen.")
except Exception as e:
print(f"Error displaying image: {e}")
@app.post("/upload/")
async def upload_image(file: UploadFile = File(...), background_tasks: BackgroundTasks = None):
# Check if the uploaded file is a JPEG
if not file.filename.lower().endswith(('jpeg', 'jpg')):
raise HTTPException(status_code=400, detail="Invalid file format. Only JPEG images are supported.")
try:
# Read the image file
contents = await file.read()
image = Image.open(BytesIO(contents))
# Process the image without changing its color format
processed_image = process_image(image)
# Add the display task to the background to not block the response
background_tasks.add_task(display_image, processed_image)
# Return immediate response indicating that processing has started
return JSONResponse(content={"filename": file.filename, "status": "Image is being processed and displayed on ePaper screen."})
except Exception as e:
print(f"Error processing image: {e}")
raise HTTPException(status_code=500, detail="Failed to process and display the image.")
@app.get("/")
async def main():
# HTML Form to upload a file
content = """
<html>
<head>
<script>
function showLoading() {
document.getElementById('loading').style.display = 'block';
}
</script>
</head>
<body>
<h1>Upload a JPEG Image (640x400px)</h1>
<form action="/upload/" enctype="multipart/form-data" method="post" onsubmit="showLoading()">
<input name="file" type="file" accept="image/jpeg" required>
<input type="submit" value="Upload Image">
</form>
<div id="loading" style="display:none;">
<p>Uploading and processing your image... Please wait!</p>
</div>
</body>
</html>
"""
return HTMLResponse(content=content)
# Ensure this block only runs when the script is executed directly
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
#!/usr/bin/env bash
set -euo pipefail
# ============================================
# Config
# ============================================
APP_USER="${APP_USER:-${SUDO_USER:-pi}}"
APP_GROUP="${APP_GROUP:-${APP_USER}}"
HOME_DIR="$(getent passwd "${APP_USER}" | cut -d: -f6)"
APP_DIR="${APP_DIR:-${HOME_DIR}/inky-app}"
VENV_DIR="${VENV_DIR:-${APP_DIR}/.venv}"
SERVICE_NAME="${SERVICE_NAME:-inky-app.service}"
# Use uv-managed Python EXACTLY 3.10.14
UV_BIN="/usr/local/bin/uv"
PY_SPEC="3.10.14" # hard pin so we never slide to 3.11
# ============================================
# Root check
# ============================================
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo bash ./deploy-inky-app-uv-py310.sh"
exit 1
fi
# ============================================
# App dir and main.py
# ============================================
install -d -o "${APP_USER}" -g "${APP_GROUP}" "${APP_DIR}"
cat > "${APP_DIR}/main.py" <<'__MAIN_PY__'
#!/usr/bin/env python3
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import uvicorn, os, time
# If using an Inky display, uncomment and adapt:
# from inky.auto import auto
# inky = auto()
app = FastAPI(title="Inky Service")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"],
)
@app.get("/")
def root():
# Example:
# inky.set_border(inky.BLACK); inky.show()
return {"status": "ok", "msg": "Inky service running"}
@app.get("/healthz")
def healthz():
return {"ok": True, "ts": time.time()}
if __name__ == "__main__":
host = os.environ.get("HOST","0.0.0.0")
port = int(os.environ.get("PORT","8000"))
uvicorn.run("main:app", host=host, port=port, reload=False)
__MAIN_PY__
chown "${APP_USER}:${APP_GROUP}" "${APP_DIR}/main.py"
chmod 0644 "${APP_DIR}/main.py"
# ============================================
# OS deps (for Pillow/inky + SPI/I2C tools)
# ============================================
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y \
curl ca-certificates git \
libopenjp2-7 libtiff5 libjpeg-dev zlib1g-dev libfreetype6-dev \
liblcms2-dev libwebp-dev libatlas-base-dev \
i2c-tools raspi-config
# ============================================
# Enable SPI & I2C (idempotent)
# ============================================
if command -v raspi-config >/dev/null 2>&1; then
raspi-config nonint do_spi 0 || true
raspi-config nonint do_i2c 0 || true
raspi-config nonint do_boot_wait 0 || true
fi
for CFG in /boot/firmware/config.txt /boot/config.txt; do
[[ -f "$CFG" ]] || continue
grep -q '^dtparam=spi=on' "$CFG" || echo 'dtparam=spi=on' >> "$CFG"
grep -q '^dtparam=i2c_arm=on' "$CFG" || e_
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment