Add FastAPI server: pulls from Immich, pre-processes for the panel
Implements the server side of the architecture decided on: the ESP32-C6 has no PSRAM and a tight RAM budget, so all the heavy lifting (JPEG decode, resize, Floyd-Steinberg dithering, 6-color quantization, 4bpp packing) happens here instead of on-device. The frame just does a single GET and streams the response straight to SPI. - GET /frame/image: looks up the current cursor's asset in the configured Immich album, downloads its preview thumbnail, and returns it packed into the panel's exact 800x480/4bpp/2px-per-byte format (application/octet-stream, always exactly 192,000 bytes). - GET / + POST /api/config + GET /api/albums: a small web UI for entering the Immich URL/API key and picking an album, rather than cramming that into the ESP32's captive portal form. - Config (Immich creds, selected album, cursor) persists to a JSON file via a docker-compose volume mount. Verified locally with a venv (Docker isn't available in this environment): unit-tested image_pipeline against a synthetic image (exact byte count, valid panel color codes only), and ran a full end-to-end pass against a mock Immich HTTP server exercising the real /frame/image path. Pinned dependency versions in requirements.txt after hitting a real bug with unpinned floors: the latest starlette (1.3.1) resolved by `pip install fastapi` breaks Jinja2Templates outright. Not yet wired to the ESP32 side (task 6) or authenticated -- /frame/image is unauthenticated for now, fine on a trusted LAN but worth revisiting once the firmware sends a shared device token.
This commit is contained in:
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8420
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8420"]
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# ESPresso Frame Server
|
||||||
|
|
||||||
|
Pulls photos from an [Immich](https://immich.app) album, resizes/dithers/quantizes
|
||||||
|
them to the E Ink Spectra 6 panel's exact 6-color format, and serves the
|
||||||
|
frame a ready-to-display image once an hour. All the image processing
|
||||||
|
happens here so the ESP32 never has to decode a JPEG or run a dithering
|
||||||
|
algorithm itself -- it just streams the response straight to the panel.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. **Get an Immich API key**: in Immich, go to Account Settings -> API Keys
|
||||||
|
-> New API Key. Read-only access to albums/assets is enough.
|
||||||
|
2. **Run the server**:
|
||||||
|
```
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
3. Open `http://<this-machine>:8420/` in a browser, enter your Immich URL
|
||||||
|
and API key, click **Load Albums**, pick one, and **Save**.
|
||||||
|
4. On the ESP32's captive portal setup form, set the **Tools Server** field
|
||||||
|
to `<this-machine>:8420`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /` -- config UI
|
||||||
|
- `GET /api/albums` -- lists Immich albums (used by the config UI)
|
||||||
|
- `POST /api/config` -- saves Immich URL/API key/album/order
|
||||||
|
- `GET /frame/image` -- returns the current photo pre-processed into the
|
||||||
|
panel's raw 800x480, 4-bit-per-pixel, 2-pixels-per-byte format
|
||||||
|
(`application/octet-stream`, exactly 192,000 bytes)
|
||||||
|
- `GET /health` -- liveness check
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Config (including the Immich API key) is stored in `./data/config.json`
|
||||||
|
on the host via the compose volume mount.
|
||||||
|
- `/frame/image` isn't authenticated yet. That's fine on a trusted home
|
||||||
|
LAN for now, but worth revisiting once the ESP32 side is wired up to
|
||||||
|
send a shared device token.
|
||||||
|
- The 6-color palette RGB values in `app/image_pipeline.py` are
|
||||||
|
approximations, not measured values (Waveshare doesn't publish exact
|
||||||
|
color primaries for this panel) -- tune them once you can compare a
|
||||||
|
rendered test image against the real panel.
|
||||||
|
|
||||||
|
## Local development (without Docker)
|
||||||
|
|
||||||
|
```
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
CONFIG_PATH=./data/config.json uvicorn app.main:app --reload --port 8420
|
||||||
|
```
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""JSON-file-backed config: Immich connection, selected album, and cursor
|
||||||
|
state (which photo /frame/image serves next)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
CONFIG_PATH = Path(os.environ.get("CONFIG_PATH", "/data/config.json"))
|
||||||
|
|
||||||
|
_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class FrameConfig(BaseModel):
|
||||||
|
immich_url: str = ""
|
||||||
|
immich_api_key: str = ""
|
||||||
|
album_id: str = ""
|
||||||
|
order: str = "sequential" # or "shuffle"
|
||||||
|
cursor: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> FrameConfig:
|
||||||
|
with _lock:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return FrameConfig()
|
||||||
|
return FrameConfig(**json.loads(CONFIG_PATH.read_text()))
|
||||||
|
|
||||||
|
|
||||||
|
def save(cfg: FrameConfig) -> None:
|
||||||
|
with _lock:
|
||||||
|
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
CONFIG_PATH.write_text(cfg.model_dump_json(indent=2))
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Resize, quantize, and pack a photo into the panel's raw 4bpp format."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
EPD_WIDTH = 800
|
||||||
|
EPD_HEIGHT = 480
|
||||||
|
|
||||||
|
# Approximate sRGB for each of the panel's 6 ink colors. These are
|
||||||
|
# reasonable placeholders, not measured values -- Waveshare doesn't publish
|
||||||
|
# exact color primaries for this panel. Tune them once you can compare a
|
||||||
|
# rendered test image against the real panel.
|
||||||
|
PALETTE_RGB = [
|
||||||
|
(0, 0, 0), # BLACK
|
||||||
|
(255, 255, 255), # WHITE
|
||||||
|
(255, 219, 0), # YELLOW
|
||||||
|
(207, 0, 15), # RED
|
||||||
|
(0, 39, 133), # BLUE
|
||||||
|
(0, 133, 55), # GREEN
|
||||||
|
]
|
||||||
|
|
||||||
|
# The panel's actual 4-bit color codes (see firmware/components/epd7in3e),
|
||||||
|
# in the same order as PALETTE_RGB. 0x4 is intentionally unused upstream.
|
||||||
|
PANEL_CODES = [0x0, 0x1, 0x2, 0x3, 0x5, 0x6]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_palette_image() -> Image.Image:
|
||||||
|
pal_img = Image.new("P", (1, 1))
|
||||||
|
pal_img.putpalette([channel for rgb in PALETTE_RGB for channel in rgb])
|
||||||
|
return pal_img
|
||||||
|
|
||||||
|
|
||||||
|
_PALETTE_IMAGE = _build_palette_image()
|
||||||
|
|
||||||
|
|
||||||
|
def render_frame(source: Image.Image) -> bytes:
|
||||||
|
"""Fits `source` to the panel's resolution, quantizes it to the 6-color
|
||||||
|
palette with Floyd-Steinberg dithering, and packs 2 pixels/byte the way
|
||||||
|
epd7in3e.c expects. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes.
|
||||||
|
"""
|
||||||
|
fitted = ImageOps.exif_transpose(source.convert("RGB"))
|
||||||
|
fitted = ImageOps.fit(fitted, (EPD_WIDTH, EPD_HEIGHT), method=Image.LANCZOS)
|
||||||
|
|
||||||
|
quantized = fitted.quantize(palette=_PALETTE_IMAGE, dither=Image.Dither.FLOYDSTEINBERG)
|
||||||
|
pixels = quantized.load()
|
||||||
|
|
||||||
|
out = bytearray(EPD_WIDTH * EPD_HEIGHT // 2)
|
||||||
|
i = 0
|
||||||
|
for y in range(EPD_HEIGHT):
|
||||||
|
for x in range(0, EPD_WIDTH, 2):
|
||||||
|
left = PANEL_CODES[pixels[x, y]]
|
||||||
|
right = PANEL_CODES[pixels[x + 1, y]]
|
||||||
|
out[i] = (left << 4) | right
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return bytes(out)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Thin wrapper around the bits of the Immich API this project needs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class ImmichClient:
|
||||||
|
def __init__(self, base_url: str, api_key: str):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self._headers = {"x-api-key": api_key}
|
||||||
|
|
||||||
|
def list_albums(self) -> list[dict]:
|
||||||
|
resp = httpx.get(f"{self.base_url}/api/albums", headers=self._headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def get_album(self, album_id: str) -> dict:
|
||||||
|
resp = httpx.get(f"{self.base_url}/api/albums/{album_id}", headers=self._headers, timeout=10)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
def download_asset_preview(self, asset_id: str) -> bytes:
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{self.base_url}/api/assets/{asset_id}/thumbnail",
|
||||||
|
params={"size": "preview"},
|
||||||
|
headers=self._headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.content
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""ESPresso Frame server: pulls photos from Immich, pre-processes them for
|
||||||
|
the panel, and serves the ESP32 a ready-to-display frame."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import random
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, Form, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .image_pipeline import render_frame
|
||||||
|
from .immich_client import ImmichClient
|
||||||
|
|
||||||
|
app = FastAPI(title="ESPresso Frame Server")
|
||||||
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
cfg = config.load()
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request, "cfg": cfg})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/albums")
|
||||||
|
def api_albums():
|
||||||
|
cfg = config.load()
|
||||||
|
if not cfg.immich_url or not cfg.immich_api_key:
|
||||||
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||||
|
try:
|
||||||
|
albums = ImmichClient(cfg.immich_url, cfg.immich_api_key).list_albums()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
||||||
|
return [{"id": a["id"], "name": a["albumName"], "count": a.get("assetCount", 0)} for a in albums]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/config")
|
||||||
|
def api_config_save(
|
||||||
|
immich_url: str = Form(""),
|
||||||
|
immich_api_key: str = Form(""),
|
||||||
|
album_id: str = Form(""),
|
||||||
|
order: str = Form("sequential"),
|
||||||
|
):
|
||||||
|
cfg = config.load()
|
||||||
|
cfg.immich_url = immich_url.strip()
|
||||||
|
cfg.immich_api_key = immich_api_key.strip()
|
||||||
|
if album_id != cfg.album_id:
|
||||||
|
cfg.cursor = 0 # restart from the top of a newly selected album
|
||||||
|
cfg.album_id = album_id
|
||||||
|
cfg.order = order if order in ("sequential", "shuffle") else "sequential"
|
||||||
|
config.save(cfg)
|
||||||
|
return {"status": "saved"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/frame/image")
|
||||||
|
def frame_image():
|
||||||
|
cfg = config.load()
|
||||||
|
if not cfg.immich_url or not cfg.immich_api_key:
|
||||||
|
raise HTTPException(400, "Immich URL/API key not configured yet")
|
||||||
|
if not cfg.album_id:
|
||||||
|
raise HTTPException(400, "No album configured yet")
|
||||||
|
|
||||||
|
client = ImmichClient(cfg.immich_url, cfg.immich_api_key)
|
||||||
|
try:
|
||||||
|
album = client.get_album(cfg.album_id)
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"Could not reach Immich at {cfg.immich_url}: {e}") from e
|
||||||
|
|
||||||
|
assets = album.get("assets", [])
|
||||||
|
if not assets:
|
||||||
|
raise HTTPException(404, "Album has no photos")
|
||||||
|
|
||||||
|
if cfg.order == "shuffle":
|
||||||
|
asset = random.choice(assets)
|
||||||
|
else:
|
||||||
|
index = cfg.cursor % len(assets)
|
||||||
|
asset = assets[index]
|
||||||
|
cfg.cursor = (index + 1) % len(assets)
|
||||||
|
config.save(cfg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
jpeg_bytes = client.download_asset_preview(asset["id"])
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(502, f"Could not download asset from Immich: {e}") from e
|
||||||
|
|
||||||
|
source = Image.open(io.BytesIO(jpeg_bytes))
|
||||||
|
frame_bytes = render_frame(source)
|
||||||
|
|
||||||
|
return Response(content=frame_bytes, media_type="application/octet-stream")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>ESPresso Frame</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 40px auto; padding: 0 16px; color: #222; }
|
||||||
|
h1 { font-size: 20px; }
|
||||||
|
p.sub { color: #666; font-size: 14px; margin-top: -8px; }
|
||||||
|
label { display: block; margin-top: 16px; font-size: 13px; font-weight: 600; }
|
||||||
|
input, select { width: 100%; padding: 8px; box-sizing: border-box; margin-top: 4px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px; }
|
||||||
|
button { margin-top: 20px; padding: 10px 16px; border: none; border-radius: 4px; background: #2563eb; color: white; cursor: pointer; font-size: 14px; }
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
|
button.secondary { background: #6b7280; margin-right: 8px; }
|
||||||
|
.status { margin-top: 16px; padding: 10px; border-radius: 4px; font-size: 14px; }
|
||||||
|
.status.ok { background: #dcfce7; color: #166534; }
|
||||||
|
.status.err { background: #fee2e2; color: #991b1b; }
|
||||||
|
code { background: #f3f4f6; padding: 2px 5px; border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>ESPresso Frame</h1>
|
||||||
|
<p class="sub">Point your frame's "Tools Server" field at this server's <code>host:port</code>.</p>
|
||||||
|
|
||||||
|
<form id="config-form">
|
||||||
|
<label>Immich URL
|
||||||
|
<input type="text" id="immich_url" value="{{ cfg.immich_url }}" placeholder="http://192.168.1.10:2283" required>
|
||||||
|
</label>
|
||||||
|
<label>Immich API Key
|
||||||
|
<input type="password" id="immich_api_key" value="{{ cfg.immich_api_key }}" required>
|
||||||
|
</label>
|
||||||
|
<label>Album
|
||||||
|
<select id="album_id">
|
||||||
|
{% if cfg.album_id %}<option value="{{ cfg.album_id }}" selected>(current selection -- reload to rename)</option>{% endif %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Order
|
||||||
|
<select id="order">
|
||||||
|
<option value="sequential" {% if cfg.order == "sequential" %}selected{% endif %}>Sequential</option>
|
||||||
|
<option value="shuffle" {% if cfg.order == "shuffle" %}selected{% endif %}>Shuffle</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="secondary" id="load-albums">Load Albums</button>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const resultEl = document.getElementById('result');
|
||||||
|
|
||||||
|
function showStatus(ok, message) {
|
||||||
|
resultEl.innerHTML = `<div class="status ${ok ? 'ok' : 'err'}">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
immich_url: document.getElementById('immich_url').value,
|
||||||
|
immich_api_key: document.getElementById('immich_api_key').value,
|
||||||
|
album_id: document.getElementById('album_id').value || '',
|
||||||
|
order: document.getElementById('order').value,
|
||||||
|
});
|
||||||
|
const resp = await fetch('/api/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(await resp.text());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('load-albums').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await saveConfig();
|
||||||
|
|
||||||
|
const resp = await fetch('/api/albums');
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(await resp.text());
|
||||||
|
}
|
||||||
|
const albums = await resp.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('album_id');
|
||||||
|
select.innerHTML = '';
|
||||||
|
for (const a of albums) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = a.id;
|
||||||
|
opt.textContent = `${a.name} (${a.count})`;
|
||||||
|
select.appendChild(opt);
|
||||||
|
}
|
||||||
|
showStatus(true, `Loaded ${albums.length} album(s) -- pick one and click Save.`);
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await saveConfig();
|
||||||
|
showStatus(true, 'Saved.');
|
||||||
|
} catch (e) {
|
||||||
|
showStatus(false, e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
espresso-frame-server:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8420:8420"
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
environment:
|
||||||
|
- CONFIG_PATH=/data/config.json
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.115.6
|
||||||
|
starlette==0.41.3
|
||||||
|
uvicorn[standard]==0.34.0
|
||||||
|
httpx==0.28.1
|
||||||
|
pillow==11.1.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
jinja2==3.1.5
|
||||||
Reference in New Issue
Block a user