diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..7917124 --- /dev/null +++ b/server/Dockerfile @@ -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"] diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..8a9ae40 --- /dev/null +++ b/server/README.md @@ -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://: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 `: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 +``` diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/config.py b/server/app/config.py new file mode 100644 index 0000000..80181a7 --- /dev/null +++ b/server/app/config.py @@ -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)) diff --git a/server/app/image_pipeline.py b/server/app/image_pipeline.py new file mode 100644 index 0000000..b9771f5 --- /dev/null +++ b/server/app/image_pipeline.py @@ -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) diff --git a/server/app/immich_client.py b/server/app/immich_client.py new file mode 100644 index 0000000..fe32743 --- /dev/null +++ b/server/app/immich_client.py @@ -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 diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..5f10348 --- /dev/null +++ b/server/app/main.py @@ -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") diff --git a/server/app/templates/index.html b/server/app/templates/index.html new file mode 100644 index 0000000..cdd1c04 --- /dev/null +++ b/server/app/templates/index.html @@ -0,0 +1,108 @@ + + + + + + ESPresso Frame + + + +

ESPresso Frame

+

Point your frame's "Tools Server" field at this server's host:port.

+ +
+ + + + + + +
+
+ + + + diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..b828eb3 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,10 @@ +services: + espresso-frame-server: + build: . + ports: + - "8420:8420" + volumes: + - ./data:/data + environment: + - CONFIG_PATH=/data/config.json + restart: unless-stopped diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..bc4896b --- /dev/null +++ b/server/requirements.txt @@ -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