commit 51f5ffcd66d3c45cdfe61e7fc734f7daf9d2e9c3 Author: Padreug Date: Sat May 16 12:17:27 2026 +0200 Initial scaffold: README, project layout, and headless-chromium render loop sketch diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..396260f --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.envrc +.DS_Store + +# Local cached renders +out/ +cache/ + +# Reference clones (keep tracked source-of-truth elsewhere) +reference/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..31e77ac --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# InkyImpression + +Display server for a 13.3" Pimoroni Inky Impression on a Raspberry Pi Zero 2 W. + +## Hardware + +- **Display**: [Inky Impression 13.3"](https://shop.pimoroni.com/products/inky-impression) — 1600×1200, 6 colors (Spectra 6: red/green/blue/yellow/black/white), ~12s refresh (real-world 20–35s). +- **Host**: Raspberry Pi Zero 2 W (quad-core ARMv7, 512 MB RAM — enough for headless Chromium on a single page). +- **Buttons**: 4 tactile buttons on the back. For the 13.3" variant they map to BCM GPIO **5, 6, 25, 24** (note: 25, not 16 — see `reference/inky/examples/spectra6/buttons.py`). +- **Mounting**: Pi Zero plugs straight into the socket header on the back. USB ports face down; text on Pi and Inky reads the same way up. **Power off before attaching/removing.** + +## What this project does + +Drives the Inky Impression to cycle through display "scenes": + +1. **Tasks** — pulled from the webapp tasks module (`~/dev/webapp/src/modules/tasks`). +2. **Restaurant menu** — shown during the lunch window (12:00–13:00). +3. **Announcements / events** — cycled via the 4 hardware buttons. + +## Architecture + +**Current strategy (this branch): headless Chromium on the Pi.** + +The webapp already renders the scenes we want (`tasks.ariege.io`, etc.), so the Pi runs `chromium --headless --screenshot` against the live URL, then pushes the PNG through PIL into the `inky` library. E-ink refresh is 20–35 s anyway, so a 5–15 s screenshot is comfortably within budget. Inky's `set_image()` handles dithering to the 6-color Spectra 6 palette. + +Other strategies we may try on branches later: + +- **Webapp JSON → PIL**: lighter, no browser, but layouts have to be redrawn by hand. +- **External render service**: only worth it if multiple Pis share renders, or if Chromium-on-Pi proves too slow/flaky. + +## Initial Pi setup + +Flash **Raspberry Pi OS Bookworm or later** (the "with desktop" image is recommended by Pimoroni — pulls in the dependencies, but Lite works if you install them manually). + +On first boot: + +```bash +sudo raspi-config +# Interfacing Options → enable SPI and I2C +sudo apt update && sudo apt upgrade -y +sudo apt install -y git python3-venv python3-pip chromium-browser +``` + +Install the Inky library via Pimoroni's installer (creates `~/.virtualenvs/pimoroni`): + +```bash +git clone https://github.com/pimoroni/inky +cd inky +./install.sh +source ~/.virtualenvs/pimoroni/bin/activate +``` + +Verify the display is detected: + +```bash +python3 -c "from inky.auto import auto; i = auto(verbose=True); print(i.resolution, i.colour)" +``` + +Quick smoke test using the bundled example: + +```bash +cd ~/inky/examples/spectra6 +python3 image.py --file images/spectra6-1600x1200.png +``` + +Then clone this repo on the Pi and install into the same venv: + +```bash +git clone ~/InkyImpression +cd ~/InkyImpression +pip install -e . +inky-impression # runs the main loop +``` + +## Project layout + +``` +. +├── README.md +├── pyproject.toml +├── src/inky_impression/ +│ ├── __init__.py +│ ├── __main__.py # `python -m inky_impression` +│ ├── main.py # scheduler loop +│ ├── render.py # chromium screenshot → PIL → Inky +│ └── scenes.py # which URL to show at what time +└── reference/inky/ # cloned Pimoroni library (gitignored) +``` + +## Reference + +- Pimoroni library (cloned for reference): `reference/inky/` + - `inky/inky_el133uf1.py` — driver for the 13.3" Spectra 6 panel + - `examples/spectra6/image.py` — minimal "draw an image" example + - `examples/spectra6/buttons.py` — button GPIO setup (remember: SW_C = 25 for 13.3") +- [Pimoroni getting-started article](https://learn.pimoroni.com/article/getting-started-with-inky-impression) +- Webapp tasks module: `~/dev/webapp/src/modules/tasks` + +## Version control + +This repo uses [jj](https://github.com/martinvonz/jj) (colocated with git): + +```bash +jj st # status +jj describe # set commit message for working copy +jj new # start a new change +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3a04b48 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "inky-impression" +version = "0.1.0" +description = "Display server for a 13.3\" Pimoroni Inky Impression on a Raspberry Pi Zero 2 W" +requires-python = ">=3.11" +dependencies = [ + "inky[example-depends]>=2.0.0", + "Pillow>=10.0.0", + "gpiod>=2.0.0", + "gpiodevice>=0.0.5", +] + +[project.scripts] +inky-impression = "inky_impression.main:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/inky_impression/__init__.py b/src/inky_impression/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/inky_impression/__main__.py b/src/inky_impression/__main__.py new file mode 100644 index 0000000..40e2b01 --- /dev/null +++ b/src/inky_impression/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/src/inky_impression/main.py b/src/inky_impression/main.py new file mode 100644 index 0000000..6198d66 --- /dev/null +++ b/src/inky_impression/main.py @@ -0,0 +1,29 @@ +import logging +import sys +import time + +from .render import show_url +from .scenes import current_scene + +REFRESH_INTERVAL_S = 300 + +log = logging.getLogger("inky_impression") + + +def main() -> int: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") + last_url: str | None = None + while True: + scene = current_scene() + if scene.url != last_url: + log.info("scene change → %s (%s)", scene.name, scene.url) + try: + show_url(scene.url) + last_url = scene.url + except Exception: + log.exception("render failed for %s", scene.url) + time.sleep(REFRESH_INTERVAL_S) + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/src/inky_impression/render.py b/src/inky_impression/render.py new file mode 100644 index 0000000..dfc3191 --- /dev/null +++ b/src/inky_impression/render.py @@ -0,0 +1,45 @@ +import pathlib +import shutil +import subprocess +import tempfile + +from PIL import Image +from inky.auto import auto + +CHROMIUM_BIN = shutil.which("chromium") or shutil.which("chromium-browser") + + +def screenshot_url(url: str, width: int, height: int, out: pathlib.Path) -> None: + if not CHROMIUM_BIN: + raise RuntimeError("chromium not found on PATH; apt install chromium-browser") + subprocess.run( + [ + CHROMIUM_BIN, + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--hide-scrollbars", + "--virtual-time-budget=5000", + f"--window-size={width},{height}", + f"--screenshot={out}", + url, + ], + check=True, + # chromium writes the PNG to CWD when --screenshot is bare; pass a full + # path and run with cwd=parent so the file lands where we expect. + cwd=out.parent, + ) + + +def show_url(url: str, *, saturation: float = 0.5) -> None: + inky = auto(ask_user=False, verbose=False) + w, h = inky.resolution + with tempfile.TemporaryDirectory() as tmp: + out = pathlib.Path(tmp) / "screen.png" + screenshot_url(url, w, h, out) + img = Image.open(out).convert("RGB").resize((w, h)) + try: + inky.set_image(img, saturation=saturation) + except TypeError: + inky.set_image(img) + inky.show() diff --git a/src/inky_impression/scenes.py b/src/inky_impression/scenes.py new file mode 100644 index 0000000..1591610 --- /dev/null +++ b/src/inky_impression/scenes.py @@ -0,0 +1,22 @@ +from dataclasses import dataclass +from datetime import datetime, time + + +@dataclass(frozen=True) +class Scene: + name: str + url: str + + +TASKS = Scene("tasks", "https://tasks.ariege.io") +MENU = Scene("menu", "https://menu.ariege.io") # TODO: real URL + +LUNCH_START = time(12, 0) +LUNCH_END = time(13, 0) + + +def current_scene(now: datetime | None = None) -> Scene: + now = now or datetime.now() + if LUNCH_START <= now.time() < LUNCH_END: + return MENU + return TASKS