From 81f28a43c290ae607cce5a38cdb6a56eef1a06fa Mon Sep 17 00:00:00 2001 From: txoof-bot <337660340+txoof-bot@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:21:06 +0200 Subject: [PATCH 1/3] Example pictures in the Plugin Library (M5 part 3b-2) Each plugin in the Library shows a picture drawn with its sample data, at the screen's size and type. Pictures are drawn ahead in the background and saved in library-pictures/ in the state folder; the file name holds a fingerprint of the plugin's files, the screen and the versions, so new or changed plugins and another screen are drawn again and old pictures go. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/decisions/web-interface.md | 6 +- src/paperpi/cli.py | 2 + src/paperpi/limits.py | 4 + src/paperpi/web/app.py | 48 +++++- src/paperpi/web/pictures.py | 230 +++++++++++++++++++++++++ src/paperpi/web/server.py | 21 ++- src/paperpi/web/static/style.css | 4 +- src/paperpi/web/templates/library.html | 5 + src/paperpi/web/templates/picture.html | 14 ++ tests/test_web_pictures.py | 215 +++++++++++++++++++++++ 11 files changed, 536 insertions(+), 17 deletions(-) create mode 100644 src/paperpi/web/pictures.py create mode 100644 src/paperpi/web/templates/picture.html create mode 100644 tests/test_web_pictures.py diff --git a/README.md b/README.md index 8d94ee5..e44d7b0 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,11 @@ uv run paperpi run --config paperpi.toml --out screen/ --state-dir state/ --heal - It runs until Ctrl+C, or until it gets the stop signal (`kill `). - After changing the config file, send it the reload signal SIGHUP (a standard message to a running program, here meaning "read your settings again"): `kill -HUP `. The process id is printed at the start. Use exactly that number: `pkill -f` would also reach PaperPi's helper processes and stop them. Once PaperPi is installed as a service (M6), `systemctl reload paperpi` does the same. Changes are applied without a restart; a broken file is not applied, and the old settings keep running. - While it runs, it says "still running" every 30 seconds: to systemd, when it runs as a service, and in the file `/run/paperpi/health`. Only root or the service can make `/run/paperpi`, so the example above uses `--health-file` to put the file in your own folder in `/run/user/`, which is also kept in memory (a file that can't be written is only a warning). `paperpi health` (with the same `--health-file`) prints the last report and ends with an error when there is no report or the last one is more than 2 minutes old. Once an hour the same values (time since the last screen write, whether screen writes work, memory use, open files, free disk) also go to the log, starting with a `health:` line at the start. If PaperPi gets stuck, systemd (or Docker, from M6) uses this to restart it: [docs/decisions/errors-and-time-limits.md](docs/decisions/errors-and-time-limits.md). -- `--state-dir` holds the plugins' own folders, the last good copy of the config and the time PaperPi last exited because of a stuck screen (default `/var/lib/paperpi`). The PNG files go to `screen/` in it, unless `--out` names another folder. +- `--state-dir` holds the plugins' own folders, the last good copy of the config, the Plugin Library's example pictures and the time PaperPi last exited because of a stuck screen (default `/var/lib/paperpi`). The PNG files go to `screen/` in it, unless `--out` names another folder. ### The web interface -`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. +`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi, with an example picture drawn at your screen's size from the plugin's sample data; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. - **First visit:** the first person to open it sets the web password (at least 8 characters). The browser then stays logged in for a year, until **Log out**. Log out only logs out that browser; a new password logs out every browser. - **Forgotten password:** you need access to the Pi itself (a keyboard and screen, or SSH). diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 2c020e9..a29ae90 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -23,7 +23,7 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s *Added in M5 part 2b (issue #238), agreed with txoof on 2026-10-07.* - **Active Plugins** lists every `[[plugin]]` block in the config file, in file order (the order the plugins take turns), also blocks with errors. Each has: Switch on / Switch off, **Up** and **Down** buttons (no dragging, so no extra JavaScript library; works the same on a phone), and Remove, which asks first. -- **Plugin Library** lists every plugin type that comes with PaperPi, with its one-line description, except `default` (PaperPi's own "nothing to show" message) and `debugging` (for testing PaperPi). Choosing one asks for a name (a free one is suggested, for example "Basic clock 2"; it may be left empty) and adds the block at the end of Active Plugins with a new `id` (see `config-format.md`, M5 part 3a-0). Active Plugins shows the name, and the `id` next to the type when it differs, so two plugins with the same name can be told apart. Sample pictures follow in part 3b. +- **Plugin Library** lists every plugin type that comes with PaperPi, with its one-line description, except `default` (PaperPi's own "nothing to show" message) and `debugging` (for testing PaperPi). Choosing one asks for a name (a free one is suggested, for example "Basic clock 2"; it may be left empty) and adds the block at the end of Active Plugins with a new `id` (see `config-format.md`, M5 part 3a-0). Active Plugins shows the name, and the `id` next to the type when it differs, so two plugins with the same name can be told apart. Each has an example picture (see "Preview images"). - A plugin with required settings (see `plugin-interface.md`) is added **switched off**, and the add page lists those settings with their help text (for `met_no`: a real email address, as met.no's terms ask). Active Plugins shows "Needs settings: ..." and can't switch it on until they are filled in. *M5 part 3a-2 (agreed with txoof on 2026-10-08):* after adding, the new plugin's settings page opens at once, and each plugin in Active Plugins has a Settings link. The settings page also has a folded "Technical information" part with the plugin's `id`, type and storage folder. - Each change is saved at once (nothing else in the config file changes: comments stay, and the comments just above a `[[plugin]]` line move and are removed with its block; a comment on the same line as a setting that changes, such as `enabled =`, is lost) and PaperPi reloads the file, so it applies at once. - **Hand edits not applied yet:** a change from the web interface is made to the file as it is on disk, so hand edits stay and apply together with it. The page then says "Your hand edits to the config file were applied too." (The other choice, refusing to save until the hand edits are applied, needs an extra step every time.) @@ -68,6 +68,10 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - The picture is sent inside the page (a `data:` address), so it is never saved as a file. The pages allow such pictures (`img-src 'self' data:`). - For later plugins: an empty secret field (such as an API key) keeps the saved secret, also in a preview. A plugin that has both a secret and a web address the user can change must not send the saved secret to a changed address: then the secret must be typed again (in a preview and when saving), or the plugin allows only its own servers. Network errors in the preview should then say only "could not reach the server", so the preview can't be used to look around the home network. No plugin has such an address yet (checked in the security review of part 3b-1); this matters for e.g. the Dutch public transport plugin. - There is no "send to display" button on the settings page. A **Show on screen now** button, which starts a saved plugin's turn at once, comes with the home page in part 4. +- **Plugin Library:** each plugin has an example picture, drawn with its sample data. *Agreed with txoof on 2026-10-10 (M5 part 3b-2):* + - The pictures are drawn ahead and saved in `library-pictures/` in the state folder, so the page opens at once. They are drawn at the screen's size and for the screen's type from `[display]`, with the plugin's default settings and first layout, one at a time, each in its own process with the 10-second sample time limit. Drawing starts a minute after PaperPi starts (so the first screen update goes first), or at once when the Library page is opened. + - Each file name holds a fingerprint: a short code made from the name, size and time of last change of every file in the plugin's folder, the screen's width, height and type, and the PaperPi and epdlib versions. A new or changed plugin, or another screen, gives a new name, so its picture is drawn again; pictures that match nothing any more are deleted. The files' contents are not read, as that is too slow for the 19 MB of moon_phase images on a Pi 3. + - While a picture is missing, the page shows "Drawing the example picture…" and asks again every 3 seconds (htmx) until it is there. A picture that can't be drawn says why, and is not tried again until the plugin, the screen or PaperPi changes (or PaperPi starts again). ### Other pages diff --git a/src/paperpi/cli.py b/src/paperpi/cli.py index 6d48fdd..5a95896 100644 --- a/src/paperpi/cli.py +++ b/src/paperpi/cli.py @@ -330,6 +330,8 @@ def load() -> config.Config: loaded.web, reload=scheduler.reload, text=None if loaded.from_last_good else loaded.text, + state_dir=args.state_dir, + display=display, ) if web is not None: print(f"web interface on port {web.port}") diff --git a/src/paperpi/limits.py b/src/paperpi/limits.py index 56b181e..e53b3f5 100644 --- a/src/paperpi/limits.py +++ b/src/paperpi/limits.py @@ -18,6 +18,10 @@ #: Longest drawing a plugin's sample data may take for a preview. PREVIEW_SAMPLE = 10.0 +#: How long the Plugin Library's example pictures wait after PaperPi starts before they are +#: drawn, so the first screen update goes first. Opening the Library page starts them at once. +LIBRARY_PICTURES_WAIT = 60.0 + #: How long a plugin process may take to exit after it has handed over its image. PLUGIN_EXIT = 5.0 diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index 3e91083..f668410 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -12,8 +12,10 @@ in the file, counted from 0, and the plugin's id to check it is still there); ``/plugins//preview`` draws the settings form's values without saving them (part of the settings page) -``/library`` Plugin Library: every plugin type; ``/library/`` adds one and - opens its settings +``/library`` Plugin Library: every plugin type with an example picture + (``/library/pictures/``; ``/library//picture`` is the + picture's part of the page, asked for again until it is drawn); + ``/library/`` adds one and opens its settings ``/static/...`` the style sheet and htmx (a small JavaScript file that updates one part of a page without loading the whole page again) ================= ============================================================== @@ -35,7 +37,7 @@ from urllib.parse import urlencode, urlsplit from fastapi import FastAPI, Form, Query, Request -from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from starlette.concurrency import run_in_threadpool @@ -47,6 +49,7 @@ from .forms import Read, fields from .helpers import helper_html from .password import PasswordError, password_problem +from .pictures import Pictures from .plugins import FormErrors, PluginEditor, library, library_item from .preview import Busy, Previewer, PreviewFailed @@ -61,12 +64,17 @@ def create_app( - auth: Auth, editor: PluginEditor | None = None, previewer: Previewer | None = None + auth: Auth, + editor: PluginEditor | None = None, + previewer: Previewer | None = None, + pictures: Pictures | None = None, ) -> FastAPI: """The web interface, using ``auth`` for the password and log-in, ``editor`` to - change the plugins in the config file and ``previewer`` to draw previews.""" + change the plugins in the config file, ``previewer`` to draw previews and ``pictures`` + for the Plugin Library's example pictures (default: none).""" editor = editor or PluginEditor(auth.config_file) previewer = previewer or Previewer() + pictures = pictures or Pictures(None) app = FastAPI(title="PaperPi", docs_url=None, redoc_url=None, openapi_url=None) app.mount("/static", StaticFiles(directory=_HERE / "static"), name="static") templates = Jinja2Templates(directory=_HERE / "templates") @@ -114,7 +122,7 @@ async def check(request: Request, call_next: Callable[[Request], Awaitable[Respo "form-action 'self'; base-uri 'none'" ) response.headers["Referrer-Policy"] = "same-origin" - if not path.startswith("/static/"): + if not path.startswith(("/static/", "/library/pictures/")): response.headers["Cache-Control"] = "no-store" return response @@ -304,14 +312,36 @@ def settings_saved(index: int, plugin_id: str, done: str, hand_edits: bool): values = {"id": plugin_id, "done": done} | ({"hand": "1"} if hand_edits else {}) return RedirectResponse(f"/plugins/{index}/settings?{urlencode(values)}", 303) + def library_page(request: Request, status: int = 200, problem: str | None = None): + items = library() + shown = {item.type: pictures.shown(item.type) for item in items} + return page(request, "library.html", status, items=items, shown=shown, problem=problem) + @app.get("/library", response_class=HTMLResponse) - def library_page(request: Request): - return page(request, "library.html", items=library()) + def library_list(request: Request): + return library_page(request) + + @app.get("/library/pictures/{name}") + def library_picture(name: str): + found = pictures.path(name) + if found is None: + return Response("No such picture.", 404) + # The name changes with the picture, so the browser may keep it. + return FileResponse( + found, media_type="image/png", headers={"Cache-Control": "max-age=86400"} + ) + + @app.get("/library/{plugin_type}/picture", response_class=HTMLResponse) + def library_picture_part(request: Request, plugin_type: str): + # Only a part of the Library page (htmx), so every answer is a 200. + item = library_item(plugin_type) + shown = pictures.shown(plugin_type) if item is not None else None + return page(request, "picture.html", item=item, picture=shown) def add_page(request: Request, plugin_type: str, status: int = 200, **values): item = library_item(plugin_type) if item is None: - return page(request, "library.html", 404, items=library(), problem="No such plugin.") + return library_page(request, 404, "No such plugin.") values.setdefault("name", editor.suggested_name(item)) return page(request, "add.html", status, item=item, **values) diff --git a/src/paperpi/web/pictures.py b/src/paperpi/web/pictures.py new file mode 100644 index 0000000..5aaaa85 --- /dev/null +++ b/src/paperpi/web/pictures.py @@ -0,0 +1,230 @@ +"""Example pictures for the Plugin Library: each plugin drawn with its sample data. + +See ``docs/decisions/web-interface.md``. The pictures are drawn ahead, in the background, +at the screen's size and for the screen's type, and saved in ``library-pictures/`` in the +state folder, so the Library page opens at once (agreed with txoof, M5 part 3b-2). + +A picture's file name holds a fingerprint: a short code made from the plugin's files (the +name, size and time of last change of each file in its folder, so a change to any of them +counts), the screen's width, height and type, and the PaperPi and epdlib versions. When +one of these changes, the old picture no longer matches and a new one is drawn; pictures +that match no plugin any more are deleted. The files' contents are not read: moon_phase +alone has 19 MB of images, too much to read at every opening of the page on a Pi 3. + +Drawing starts :data:`~paperpi.limits.LIBRARY_PICTURES_WAIT` seconds after PaperPi starts +(so the first screen update goes first), or at once when the Library page is opened. One +picture is drawn at a time, each in its own plugin process, as a preview is +(:func:`paperpi.runner.run_update`), with the plugin's default settings and first layout. +""" + +from __future__ import annotations + +import hashlib +import importlib.util +import logging +import tempfile +import threading +from dataclasses import dataclass +from importlib.metadata import version +from pathlib import Path + +from epdlib import ScreenMode + +from .. import __version__, config, limits, plugins +from ..plugin import Context, State +from ..runner import PluginFailed, run_update +from .plugins import HIDDEN +from .preview import Update + +log = logging.getLogger(__name__) + +#: The folder in the state folder that holds the pictures. +FOLDER = "library-pictures" + + +@dataclass(frozen=True) +class Screen: + """What the pictures are drawn for.""" + + width: int + height: int + mode: ScreenMode + + @classmethod + def of(cls, display: config.DisplaySettings) -> Screen: + return cls(*display.layout_size, display.screen_mode) + + +@dataclass(frozen=True) +class Shown: + """What the Library page shows for one plugin.""" + + file: str = "" + """The picture's file name, once it is drawn.""" + problem: str = "" + """Why there is no picture (it could not be drawn).""" + screen: Screen | None = None + + @property + def waiting(self) -> bool: + return not self.file and not self.problem + + +class Pictures: + """Draws and keeps the example pictures in ``folder``; ``None``: no pictures at all + (e.g. tests that don't need them).""" + + def __init__( + self, + folder: Path | None, + update: Update = run_update, + *, + package: str = plugins.__name__, + wait: float = limits.LIBRARY_PICTURES_WAIT, + ): + self.folder = folder + self._update = update + self._package = package + self._wait = wait + self._screen: Screen | None = None + self._failed: dict[str, str] = {} # file name -> why it could not be drawn + self._lock = threading.Lock() + self._wake = threading.Event() + self._stopped = threading.Event() + self._thread: threading.Thread | None = None + + def use(self, screen: Screen) -> None: + """Draw the pictures for ``screen`` (at the start, and after each config reload): + those that are missing are drawn in the background.""" + if self.folder is None: + return + with self._lock: + self._screen = screen + if self._thread is None: + self._thread = threading.Thread( + target=self._work, name="paperpi-library-pictures", daemon=True + ) + self._thread.start() + return # the first round starts after the wait + self._wake.set() + + def stop(self) -> None: + """PaperPi stops: draw no more pictures.""" + self._stopped.set() + self._wake.set() + + def shown(self, plugin_type: str) -> Shown | None: + """What to show for ``plugin_type``; ``None`` when there are no pictures.""" + with self._lock: + screen = self._screen + if self.folder is None or screen is None: + return None + try: + name = self.file_name(plugin_type, screen) + except OSError as error: + return Shown(problem=f"Its files can't be read: {error.strerror}.", screen=screen) + if (self.folder / name).is_file(): + return Shown(file=name, screen=screen) + if name in self._failed: + return Shown(problem=self._failed[name], screen=screen) + self._wake.set() # a plugin changed, or the page was opened during the wait + return Shown(screen=screen) + + def path(self, name: str) -> Path | None: + """The saved picture called ``name``, or ``None`` (also for any other file name).""" + if self.folder is None or not _good_name(name): + return None + found = self.folder / name + return found if found.is_file() else None + + def file_name(self, plugin_type: str, screen: Screen) -> str: + """The file name of ``plugin_type``'s picture for ``screen``: ``-<16 hex + digits>.png``, the digits being the fingerprint (see the top).""" + spec = importlib.util.find_spec(f"{self._package}.{plugin_type}") + folder = Path(spec.submodule_search_locations[0]) + code = hashlib.sha256() + for part in (__version__, version("epdlib"), plugin_type, screen.width, screen.height): + code.update(f"{part}\n".encode()) + code.update(f"{screen.mode!r}\n".encode()) + for file in sorted(folder.rglob("*")): + if file.is_file() and "__pycache__" not in file.parts: + info = file.stat() + code.update( + f"{file.relative_to(folder)} {info.st_size} {info.st_mtime_ns}\n".encode() + ) + return f"{plugin_type}-{code.hexdigest()[:16]}.png" + + def _work(self) -> None: + self._wake.wait(self._wait) + while not self._stopped.is_set(): + self._wake.clear() + try: + self._draw_all() + except Exception: # noqa: BLE001 - keep the thread; try again at the next wake + log.exception("library pictures") + self._wake.wait() + + def _draw_all(self) -> None: + with self._lock: + screen = self._screen + self.folder.mkdir(parents=True, exist_ok=True) + keep = set() + for plugin_type in plugins.available(self._package): + if plugin_type in HIDDEN: + continue + if self._stopped.is_set(): + return + try: + name = self.file_name(plugin_type, screen) + except OSError: + continue + keep.add(name) + if not (self.folder / name).is_file() and name not in self._failed: + self._draw(plugin_type, name, screen) + for old in self.folder.glob("*.png"): + if old.name not in keep: + old.unlink(missing_ok=True) + + def _draw(self, plugin_type: str, name: str, screen: Screen) -> None: + try: + plugin = plugins.load(plugin_type, self._package) + settings = plugin.settings.model_validate({}) + except Exception as error: # noqa: BLE001 - the Library page says it is broken + self._failed[name] = f"The plugin is broken: {error}" + return + with tempfile.TemporaryDirectory(prefix="paperpi-picture-") as storage: + context = Context( + settings, + screen.width, + screen.height, + screen.mode, + Path(storage), + plugin.default_layout, + ) + try: + result = self._update( + plugin_type, context, sample=True, time_limit=limits.PREVIEW_SAMPLE + ) + except PluginFailed as error: + log.info("library picture of %s: %s", plugin_type, error) + self._failed[name] = f"Its sample data could not be drawn: {error.reason}" + return + if result.state is State.NOTHING or result.image is None: + self._failed[name] = "Its sample data gives no picture." + return + part = self.folder / f".{name}.part" + result.image.save(part, format="PNG") + part.replace(self.folder / name) # the page never sees half a file + log.debug("library picture of %s drawn in %.1f s", plugin_type, result.seconds) + + +def _good_name(name: str) -> bool: + base, _, rest = name.rpartition("-") + digits = rest.removesuffix(".png") + return ( + bool(base) + and all(c.isascii() and (c.isalnum() or c == "_") for c in base) + and rest.endswith(".png") + and len(digits) == 16 + and all(c in "0123456789abcdef" for c in digits) + ) diff --git a/src/paperpi/web/server.py b/src/paperpi/web/server.py index 7006830..58f2e5e 100644 --- a/src/paperpi/web/server.py +++ b/src/paperpi/web/server.py @@ -18,6 +18,7 @@ from .. import config from .app import create_app from .auth import Auth +from .pictures import FOLDER, Pictures, Screen from .plugins import PluginEditor from .preview import Previewer @@ -36,10 +37,12 @@ def __init__( settings: config.WebSettings, sock: socket.socket, editor: PluginEditor, + pictures: Pictures | None = None, ): self.auth = auth self.editor = editor self.previewer = Previewer() + self.pictures = pictures or Pictures(None) self.settings = settings """The settings it was started with; a change of address or port needs a restart.""" self._socket = sock @@ -47,7 +50,7 @@ def __init__( """The port it listens on (useful with ``port = 0`` in tests).""" self._server = uvicorn.Server( uvicorn.Config( - create_app(auth, self.editor, self.previewer), + create_app(auth, self.editor, self.previewer, self.pictures), log_config=None, # PaperPi's own logging stays as it is log_level="warning", access_log=False, @@ -70,6 +73,7 @@ def use(self, loaded: config.Config) -> None: settings = loaded.web self.auth.use(settings) self.editor.loaded(loaded.text) + self.pictures.use(Screen.of(loaded.display)) changed = [ k for k in config.WEB_NEXT_START if getattr(settings, k) != getattr(self.settings, k) ] @@ -81,8 +85,10 @@ def running(self) -> bool: return self._thread.is_alive() def stop_previews(self) -> None: - """Start no more previews: PaperPi is stopping (see :meth:`Previewer.stop`).""" + """Start no more previews or library pictures: PaperPi is stopping (see + :meth:`Previewer.stop`).""" self.previewer.stop() + self.pictures.stop() def stop(self) -> None: self.stop_previews() @@ -99,11 +105,15 @@ def start( *, reload: Callable[[], None] | None = None, text: str | None = None, + state_dir: Path | None = None, + display: config.DisplaySettings | None = None, ) -> WebServer | None: """Start the web interface, or log why it can't start and return ``None``. ``reload`` makes PaperPi load the config file again (after a change in the web - interface); ``text`` is the config file text PaperPi uses now. + interface); ``text`` is the config file text PaperPi uses now. The Plugin Library's + example pictures are kept in ``state_dir`` and drawn for ``display`` (none without + them). """ try: sock = _listen(settings.address, settings.port) @@ -118,8 +128,11 @@ def start( editor = PluginEditor(config_file, reload) if text is not None: editor.loaded(text) - server = WebServer(Auth(config_file, settings), settings, sock, editor) + pictures = Pictures(state_dir / FOLDER if state_dir is not None else None) + server = WebServer(Auth(config_file, settings), settings, sock, editor, pictures) server._thread.start() + if display is not None: + pictures.use(Screen.of(display)) return server diff --git a/src/paperpi/web/static/style.css b/src/paperpi/web/static/style.css index 0c450fb..67c4b1c 100644 --- a/src/paperpi/web/static/style.css +++ b/src/paperpi/web/static/style.css @@ -21,7 +21,9 @@ code { background: #eee; padding: 0 0.2rem; } .plugins .type, .plugins .hint { color: #555; font-size: 0.9rem; } .buttons { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.4rem; } .buttons form { margin: 0; display: flex; gap: 0.5rem; } -.library li { margin: 0.5rem 0; } +.library li { margin: 0.5rem 0 1.25rem; } +.picture { margin: 0.4rem 0 0; } +.picture img { display: block; width: 18rem; max-width: 100%; height: auto; border: 1px solid #999; } .settings h2 { font-size: 1.1rem; margin-top: 1.5rem; } .settings .field { margin: 0.9rem 0; } .settings .field > label { display: block; margin: 0 0 0.25rem; } diff --git a/src/paperpi/web/templates/library.html b/src/paperpi/web/templates/library.html index 2a2a007..bfc8ac3 100644 --- a/src/paperpi/web/templates/library.html +++ b/src/paperpi/web/templates/library.html @@ -7,11 +7,16 @@

Plugin Library

Every plugin that comes with PaperPi. Choose one to add it at the end of Active Plugins. The same plugin can be added more than once, with different settings (for example the weather for two places).

+{% if shown.values()|select|first %} +

Each example is drawn at your screen's size with the plugin's sample data +and default settings, not real data.

+{% endif %}
    {% for item in items %}
  • {{ item.type }}: {{ item.description }} {% if item.plugin %}Add{% endif %} + {% if item.plugin %}{% with picture = shown[item.type] %}{% include "picture.html" %}{% endwith %}{% endif %}
  • {% endfor %}
diff --git a/src/paperpi/web/templates/picture.html b/src/paperpi/web/templates/picture.html new file mode 100644 index 0000000..4d6ab86 --- /dev/null +++ b/src/paperpi/web/templates/picture.html @@ -0,0 +1,14 @@ +{#- One plugin's example picture in the Plugin Library. Also sent alone (htmx): while the +picture is being drawn, it asks again every few seconds and replaces itself. -#} +{% if picture is none %} +{%- elif picture.file %} +
+ Example of {{ item.type }}, drawn with its sample data +
+{%- elif picture.problem %} +

No example picture: {{ picture.problem|sentence }}

+{%- else %} +

Drawing the example picture…

+{%- endif %} diff --git a/tests/test_web_pictures.py b/tests/test_web_pictures.py new file mode 100644 index 0000000..da40394 --- /dev/null +++ b/tests/test_web_pictures.py @@ -0,0 +1,215 @@ +"""Example pictures in the Plugin Library (M5 part 3b-2).""" + +import html +import itertools +import shutil +import time +from pathlib import Path + +import pytest +from epdlib import ScreenMode +from fastapi.testclient import TestClient +from PIL import Image + +from paperpi import config +from paperpi.plugin import State +from paperpi.runner import PluginFailed +from paperpi.web import auth +from paperpi.web.app import create_app +from paperpi.web.pictures import Pictures, Screen +from paperpi.web.plugins import PluginEditor +from paperpi.web.preview import Previewer + +from .test_web_preview import FakeUpdate +from .test_web_settings import CONFIG, PI + +FAKE = Path(__file__).parent / "fake_plugins" / "fake" / "__init__.py" +SCREEN = Screen(300, 200, ScreenMode.gray(16)) +_names = itertools.count() + + +@pytest.fixture +def package(tmp_path, monkeypatch): + """A new plugin package: ``fake`` (works), ``default`` (hidden from the Library) and + ``broken`` (no PLUGIN). Each test gets its own name, so nothing is imported twice.""" + name = f"pictures_plugins_{next(_names)}" + root = tmp_path / "code" / name + for plugin_type in ("fake", "default"): + (root / plugin_type).mkdir(parents=True) + text = FAKE.read_text().replace('type="fake"', f'type="{plugin_type}"') + (root / plugin_type / "__init__.py").write_text(text) + (root / "broken").mkdir() + (root / "broken" / "__init__.py").write_text("") + (root / "__init__.py").write_text("") + monkeypatch.syspath_prepend(str(tmp_path / "code")) + return name + + +def until(check, seconds=5.0): + end = time.monotonic() + seconds + while not check(): + assert time.monotonic() < end, "took too long" + time.sleep(0.01) + + +def started(folder, package, update, screen=SCREEN, wait=0.0): + pictures = Pictures(folder, update, package=package, wait=wait) + pictures.use(screen) + return pictures + + +def drawn(pictures, *types): + until(lambda: all(not pictures.shown(t).waiting for t in types)) + return [pictures.shown(t) for t in types] + + +def test_each_plugin_is_drawn_once_with_its_sample_data(tmp_path, package): + update = FakeUpdate(State.READY, State.READY) + pictures = started(tmp_path / "pics", package, update) + fake, broken = drawn(pictures, "fake", "broken") + assert fake.file.startswith("fake-") and fake.file.endswith(".png") and fake.screen == SCREEN + image = Image.open(tmp_path / "pics" / fake.file) + assert image.size == (300, 200) + assert "broken" in broken.problem and not broken.file + [(plugin_type, context, sample, time_limit)] = update.calls # not default, not broken + assert (plugin_type, sample, time_limit) == ("fake", True, 10.0) + assert (context.width, context.height, context.mode) == (300, 200, SCREEN.mode) + assert context.layout == "one" and context.settings.act == "ok" + pictures.use(SCREEN) # e.g. a config reload: nothing to draw again + time.sleep(0.1) + assert len(update.calls) == 1 and pictures.shown("fake") == fake + assert sorted(p.name for p in (tmp_path / "pics").iterdir()) == [fake.file] + pictures.stop() + + +def test_a_changed_plugin_or_screen_is_drawn_again(tmp_path, package, monkeypatch): + update = FakeUpdate(*[State.READY] * 3) + pictures = started(tmp_path / "pics", package, update) + [first] = drawn(pictures, "fake") + code = Path(__import__(package).__file__).parent / "fake" / "__init__.py" + code.write_text(code.read_text() + "\n# changed\n") + [second] = drawn(pictures, "fake") # opening the page notices the change + assert second.file != first.file + other = Screen(200, 300, ScreenMode.bw()) + pictures.use(other) + until(lambda: pictures.shown("fake").file not in ("", second.file)) + third = pictures.shown("fake") + assert third.screen == other and Image.open(tmp_path / "pics" / third.file).size == (200, 300) + until(lambda: [p.name for p in (tmp_path / "pics").iterdir()] == [third.file]) + assert len(update.calls) == 3 + pictures.stop() + + +def test_a_picture_that_fails_says_why_and_is_not_tried_again(tmp_path, package): + update = FakeUpdate(PluginFailed("fake", "ValueError: no font")) + pictures = started(tmp_path / "pics", package, update) + [fake] = drawn(pictures, "fake") + assert fake.problem == "Its sample data could not be drawn: ValueError: no font" + pictures.use(SCREEN) + time.sleep(0.1) + assert len(update.calls) == 1 and pictures.shown("fake").problem == fake.problem + pictures.stop() + + +def test_nothing_to_show_is_a_problem_too(tmp_path, package): + pictures = started(tmp_path / "pics", package, FakeUpdate(State.NOTHING)) + assert drawn(pictures, "fake")[0].problem == "Its sample data gives no picture." + pictures.stop() + + +def test_drawing_waits_after_the_start_unless_the_page_is_opened(tmp_path, package): + update = FakeUpdate() + pictures = started(tmp_path / "pics", package, update, wait=60) + time.sleep(0.1) + assert update.calls == [] + assert pictures.shown("fake").waiting # the page is opened: start now + until(lambda: pictures.shown("fake").file) + pictures.stop() + + +def test_stopped_pictures_draw_nothing(tmp_path, package): + update = FakeUpdate() + pictures = started(tmp_path / "pics", package, update, wait=60) + pictures.stop() + pictures.shown("fake") + time.sleep(0.1) + assert update.calls == [] + + +def test_no_folder_means_no_pictures(package): + pictures = Pictures(None, FakeUpdate(), package=package) + pictures.use(SCREEN) + assert pictures.shown("fake") is None and pictures.path("fake-0123456789abcdef.png") is None + + +@pytest.mark.parametrize( + "name", + ["../paperpi.toml", "fake-0123456789abcdef.txt", "fake-0123.png", "-0123456789abcdef.png", + "a/b-0123456789abcdef.png", "fake-0123456789ABCDEF.png", ".fake-0123456789abcdef.png.part"], +) # fmt: skip +def test_only_picture_files_are_given_out(tmp_path, name): + folder = tmp_path / "pics" + (folder / "a").mkdir(parents=True) + (folder / name).write_bytes(b"x") # even when the file is there + assert Pictures(folder).path(name) is None + + +# The Library page. + + +def make_client(tmp_path, pictures): + cfg = tmp_path / "paperpi.toml" + cfg.write_text(CONFIG) + editor = PluginEditor(cfg) + paperpi_auth = auth.Auth(cfg, config.WebSettings(login=False)) + app = create_app(paperpi_auth, editor, Previewer(FakeUpdate()), pictures) + return TestClient(app, follow_redirects=False, base_url=PI) + + +@pytest.fixture +def ready_pictures(tmp_path): + """Real plugins, one picture saved already (as if drawn), drawing not started yet.""" + pictures = Pictures(tmp_path / "pics", FakeUpdate(), wait=60) + pictures.use(SCREEN) + name = pictures.file_name("basic_clock", SCREEN) + (tmp_path / "pics").mkdir() + Image.new("L", (300, 200), 255).save(tmp_path / "pics" / name) + yield pictures, name + pictures.stop() + + +def test_the_library_shows_drawn_pictures_and_waits_for_the_others(tmp_path, ready_pictures): + pictures, name = ready_pictures + client = make_client(tmp_path, pictures) + page = html.unescape(client.get("/library").text) + assert f' Date: Sat, 10 Oct 2026 09:28:54 +0200 Subject: [PATCH 2/3] Library pictures: review fixes Any error is shown per plugin and the others are still drawn; a folder that can't be written says so instead of drawing again at every look; slow plugins are tried 3 times; .part files are removed; links are not given out; private caching; a reload keeps the screen size and mode until the next start, as the scheduler does; more tests; doc wording. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- docs/decisions/web-interface.md | 7 +- src/paperpi/limits.py | 3 +- src/paperpi/web/app.py | 2 +- src/paperpi/web/pictures.py | 58 +++++-- src/paperpi/web/server.py | 20 ++- src/paperpi/web/templates/library.html | 4 +- src/paperpi/web/templates/picture.html | 2 +- tests/test_cli.py | 1 + tests/test_web_pictures.py | 224 ++++++++++++++++++++----- 10 files changed, 253 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index e44d7b0..f7abdef 100644 --- a/README.md +++ b/README.md @@ -95,11 +95,11 @@ uv run paperpi run --config paperpi.toml --out screen/ --state-dir state/ --heal - It runs until Ctrl+C, or until it gets the stop signal (`kill `). - After changing the config file, send it the reload signal SIGHUP (a standard message to a running program, here meaning "read your settings again"): `kill -HUP `. The process id is printed at the start. Use exactly that number: `pkill -f` would also reach PaperPi's helper processes and stop them. Once PaperPi is installed as a service (M6), `systemctl reload paperpi` does the same. Changes are applied without a restart; a broken file is not applied, and the old settings keep running. - While it runs, it says "still running" every 30 seconds: to systemd, when it runs as a service, and in the file `/run/paperpi/health`. Only root or the service can make `/run/paperpi`, so the example above uses `--health-file` to put the file in your own folder in `/run/user/`, which is also kept in memory (a file that can't be written is only a warning). `paperpi health` (with the same `--health-file`) prints the last report and ends with an error when there is no report or the last one is more than 2 minutes old. Once an hour the same values (time since the last screen write, whether screen writes work, memory use, open files, free disk) also go to the log, starting with a `health:` line at the start. If PaperPi gets stuck, systemd (or Docker, from M6) uses this to restart it: [docs/decisions/errors-and-time-limits.md](docs/decisions/errors-and-time-limits.md). -- `--state-dir` holds the plugins' own folders, the last good copy of the config, the Plugin Library's example pictures and the time PaperPi last exited because of a stuck screen (default `/var/lib/paperpi`). The PNG files go to `screen/` in it, unless `--out` names another folder. +- `--state-dir` holds the plugins' own folders, the last good copy of the config, the Plugin Library's example pictures (`library-pictures/`, a few hundred KB) and the time PaperPi last exited because of a stuck screen (default `/var/lib/paperpi`). The PNG files go to `screen/` in it, unless `--out` names another folder. ### The web interface -`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi, with an example picture drawn at your screen's size from the plugin's sample data; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. +`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi, with an example picture drawn at your screen's size from the plugin's sample data, in the background: it starts a minute after PaperPi starts or when you open the page, and until then the page says "Drawing the example picture…"; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. - **First visit:** the first person to open it sets the web password (at least 8 characters). The browser then stays logged in for a year, until **Log out**. Log out only logs out that browser; a new password logs out every browser. - **Forgotten password:** you need access to the Pi itself (a keyboard and screen, or SSH). diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index a29ae90..29df80b 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -69,9 +69,10 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - For later plugins: an empty secret field (such as an API key) keeps the saved secret, also in a preview. A plugin that has both a secret and a web address the user can change must not send the saved secret to a changed address: then the secret must be typed again (in a preview and when saving), or the plugin allows only its own servers. Network errors in the preview should then say only "could not reach the server", so the preview can't be used to look around the home network. No plugin has such an address yet (checked in the security review of part 3b-1); this matters for e.g. the Dutch public transport plugin. - There is no "send to display" button on the settings page. A **Show on screen now** button, which starts a saved plugin's turn at once, comes with the home page in part 4. - **Plugin Library:** each plugin has an example picture, drawn with its sample data. *Agreed with txoof on 2026-10-10 (M5 part 3b-2):* - - The pictures are drawn ahead and saved in `library-pictures/` in the state folder, so the page opens at once. They are drawn at the screen's size and for the screen's type from `[display]`, with the plugin's default settings and first layout, one at a time, each in its own process with the 10-second sample time limit. Drawing starts a minute after PaperPi starts (so the first screen update goes first), or at once when the Library page is opened. - - Each file name holds a fingerprint: a short code made from the name, size and time of last change of every file in the plugin's folder, the screen's width, height and type, and the PaperPi and epdlib versions. A new or changed plugin, or another screen, gives a new name, so its picture is drawn again; pictures that match nothing any more are deleted. The files' contents are not read, as that is too slow for the 19 MB of moon_phase images on a Pi 3. - - While a picture is missing, the page shows "Drawing the example picture…" and asks again every 3 seconds (htmx) until it is there. A picture that can't be drawn says why, and is not tried again until the plugin, the screen or PaperPi changes (or PaperPi starts again). + - The pictures are drawn ahead and saved in `library-pictures/` in the state folder, so the page opens at once. They are drawn at the screen's size and for the screen's type from `[display]`, with the plugin's default settings and first layout, one at a time, each in its own process with the 10-second sample time limit. Drawing starts a minute after PaperPi starts (so the first screen update goes first), or at once when the Library page is opened or the config file is loaded again. A change of the screen's type, model, size or mode applies at the next start, as for the screen itself. + - Each file name holds a fingerprint: a short code made from the name, size and time of last change of every file in the plugin's folder, the screen's width, height and type, and the PaperPi and epdlib versions. A new or changed plugin, or another screen, gives a new name, so its picture is drawn again; pictures that match nothing any more are deleted. The files' contents are not read, as that is too slow for the 19 MB of moon_phase images on a Pi 3. Only files in the plugin's own folder count; a change to PaperPi's shared code counts only through the PaperPi version. The browser may keep a picture for a day; a new picture has a new file name. + - While a picture is missing, the page shows "Drawing the example picture…" and asks again every 3 seconds (htmx) until it is there. A plugin that takes longer than 10 seconds (the Pi may just be busy) is tried up to 3 times. Any other picture that can't be drawn says why, and is not tried again until the plugin, the screen or PaperPi changes (or PaperPi starts again), so an open Library page can't keep the Pi busy. If the folder can't be written, every plugin says so. + - A plugin whose code is changed while PaperPi runs gets a new picture (each picture is drawn in a new process), but the Library's list and a plugin that was broken at the start only change after a restart, because the web interface loads each plugin's code once. ### Other pages diff --git a/src/paperpi/limits.py b/src/paperpi/limits.py index e53b3f5..16c62be 100644 --- a/src/paperpi/limits.py +++ b/src/paperpi/limits.py @@ -15,7 +15,8 @@ #: limit, if that is shorter). Then the preview draws the sample data instead. PREVIEW_LIVE = 45.0 -#: Longest drawing a plugin's sample data may take for a preview. +#: Longest drawing a plugin's sample data may take for a preview or a Plugin Library example +#: picture. PREVIEW_SAMPLE = 10.0 #: How long the Plugin Library's example pictures wait after PaperPi starts before they are diff --git a/src/paperpi/web/app.py b/src/paperpi/web/app.py index f668410..3eacebe 100644 --- a/src/paperpi/web/app.py +++ b/src/paperpi/web/app.py @@ -328,7 +328,7 @@ def library_picture(name: str): return Response("No such picture.", 404) # The name changes with the picture, so the browser may keep it. return FileResponse( - found, media_type="image/png", headers={"Cache-Control": "max-age=86400"} + found, media_type="image/png", headers={"Cache-Control": "private, max-age=86400"} ) @app.get("/library/{plugin_type}/picture", response_class=HTMLResponse) diff --git a/src/paperpi/web/pictures.py b/src/paperpi/web/pictures.py index 5aaaa85..59e68bb 100644 --- a/src/paperpi/web/pictures.py +++ b/src/paperpi/web/pictures.py @@ -9,12 +9,18 @@ counts), the screen's width, height and type, and the PaperPi and epdlib versions. When one of these changes, the old picture no longer matches and a new one is drawn; pictures that match no plugin any more are deleted. The files' contents are not read: moon_phase -alone has 19 MB of images, too much to read at every opening of the page on a Pi 3. +alone has 19 MB of images, too much to read at every opening of the page on a Pi 3. Only +files in the plugin's own folder count: a change to PaperPi's shared code (or to epdlib +without a new version number, as while developing) counts only through the version. Drawing starts :data:`~paperpi.limits.LIBRARY_PICTURES_WAIT` seconds after PaperPi starts -(so the first screen update goes first), or at once when the Library page is opened. One -picture is drawn at a time, each in its own plugin process, as a preview is -(:func:`paperpi.runner.run_update`), with the plugin's default settings and first layout. +(so the first screen update goes first), or at once when the Library page is opened or +the config file is loaded again. One picture is drawn at a time, each in its own plugin +process, as a preview is (:func:`paperpi.runner.run_update`), with the plugin's default +settings and first layout, within :data:`~paperpi.limits.PREVIEW_SAMPLE` seconds. A plugin +that takes longer is tried again, at most :data:`TRIES` times in all (the Pi may just have +been busy). Any other picture that can't be drawn says why and is not tried again until its +file name changes or PaperPi starts again, so an open Library page can't keep the Pi busy. """ from __future__ import annotations @@ -32,7 +38,7 @@ from .. import __version__, config, limits, plugins from ..plugin import Context, State -from ..runner import PluginFailed, run_update +from ..runner import PluginFailed, PluginTimeout, run_update from .plugins import HIDDEN from .preview import Update @@ -41,6 +47,9 @@ #: The folder in the state folder that holds the pictures. FOLDER = "library-pictures" +#: How often a picture is tried when the plugin takes too long. +TRIES = 3 + @dataclass(frozen=True) class Screen: @@ -52,6 +61,7 @@ class Screen: @classmethod def of(cls, display: config.DisplaySettings) -> Screen: + """The screen plugins draw for with ``display`` (turned when it is rotated).""" return cls(*display.layout_size, display.screen_mode) @@ -67,6 +77,7 @@ class Shown: @property def waiting(self) -> bool: + """Not drawn yet: the page asks again in a moment.""" return not self.file and not self.problem @@ -88,6 +99,8 @@ def __init__( self._wait = wait self._screen: Screen | None = None self._failed: dict[str, str] = {} # file name -> why it could not be drawn + self._timeouts: dict[str, int] = {} # file name -> how often it took too long + self._problem = "" # why no picture can be saved at all (the folder) self._lock = threading.Lock() self._wake = threading.Event() self._stopped = threading.Event() @@ -119,6 +132,8 @@ def shown(self, plugin_type: str) -> Shown | None: screen = self._screen if self.folder is None or screen is None: return None + if self._problem: + return Shown(problem=self._problem, screen=screen) try: name = self.file_name(plugin_type, screen) except OSError as error: @@ -135,7 +150,8 @@ def path(self, name: str) -> Path | None: if self.folder is None or not _good_name(name): return None found = self.folder / name - return found if found.is_file() else None + # Not a link to another file: only pictures PaperPi saved itself are given out. + return found if found.is_file() and not found.is_symlink() else None def file_name(self, plugin_type: str, screen: Screen) -> str: """The file name of ``plugin_type``'s picture for ``screen``: ``-<16 hex @@ -160,14 +176,16 @@ def _work(self) -> None: self._wake.clear() try: self._draw_all() - except Exception: # noqa: BLE001 - keep the thread; try again at the next wake - log.exception("library pictures") + except OSError as error: # the folder can't be made or read + log.error("library pictures can't be saved in %s: %s", self.folder, error) + self._problem = f"Pictures can't be saved: {error.strerror or error}." self._wake.wait() def _draw_all(self) -> None: with self._lock: screen = self._screen self.folder.mkdir(parents=True, exist_ok=True) + self._problem = "" # e.g. fixed before a config reload keep = set() for plugin_type in plugins.available(self._package): if plugin_type in HIDDEN: @@ -176,12 +194,17 @@ def _draw_all(self) -> None: return try: name = self.file_name(plugin_type, screen) - except OSError: + except OSError: # keep what it has; the page says its files can't be read + keep.update(p.name for p in self.folder.glob(f"{plugin_type}-*.png")) continue keep.add(name) if not (self.folder / name).is_file() and name not in self._failed: - self._draw(plugin_type, name, screen) - for old in self.folder.glob("*.png"): + try: + self._draw(plugin_type, name, screen) + except Exception as error: # noqa: BLE001 - say why; go on with the others + log.exception("library picture of %s", plugin_type) + self._failed[name] = f"It could not be drawn: {error}" + for old in [*self.folder.glob("*.png"), *self.folder.glob(".*.part")]: if old.name not in keep: old.unlink(missing_ok=True) @@ -207,14 +230,23 @@ def _draw(self, plugin_type: str, name: str, screen: Screen) -> None: ) except PluginFailed as error: log.info("library picture of %s: %s", plugin_type, error) + tries = self._timeouts[name] = self._timeouts.get(name, 0) + 1 + if isinstance(error, PluginTimeout) and tries < TRIES: + return # tried again at the next round (the page asks again) self._failed[name] = f"Its sample data could not be drawn: {error.reason}" return if result.state is State.NOTHING or result.image is None: self._failed[name] = "Its sample data gives no picture." return part = self.folder / f".{name}.part" - result.image.save(part, format="PNG") - part.replace(self.folder / name) # the page never sees half a file + try: + result.image.save(part, format="PNG") + part.replace(self.folder / name) # the page never sees half a file + except OSError as error: + part.unlink(missing_ok=True) + self._failed[name] = f"It could not be saved: {error.strerror or error}." + log.error("library picture of %s can't be saved: %s", plugin_type, error) + return log.debug("library picture of %s drawn in %.1f s", plugin_type, result.seconds) diff --git a/src/paperpi/web/server.py b/src/paperpi/web/server.py index 58f2e5e..7e9db87 100644 --- a/src/paperpi/web/server.py +++ b/src/paperpi/web/server.py @@ -43,6 +43,8 @@ def __init__( self.editor = editor self.previewer = Previewer() self.pictures = pictures or Pictures(None) + self.display: config.DisplaySettings | None = None + """The ``[display]`` settings the screen uses now (see :meth:`use_display`).""" self.settings = settings """The settings it was started with; a change of address or port needs a restart.""" self._socket = sock @@ -73,13 +75,23 @@ def use(self, loaded: config.Config) -> None: settings = loaded.web self.auth.use(settings) self.editor.loaded(loaded.text) - self.pictures.use(Screen.of(loaded.display)) + self.use_display(loaded.display) changed = [ k for k in config.WEB_NEXT_START if getattr(settings, k) != getattr(self.settings, k) ] if changed: log.warning("[web] %s: changes apply at the next start of PaperPi", ", ".join(changed)) + def use_display(self, display: config.DisplaySettings) -> None: + """Draw the Library pictures for ``display``. As in the scheduler, a change of the + screen's type, model, size or mode applies only at the next start, so the pictures + keep the values the screen started with.""" + if self.display is not None: + started = {key: getattr(self.display, key) for key in config.NEXT_START} + display = display.model_copy(update=started) + self.display = display + self.pictures.use(Screen.of(display)) + @property def running(self) -> bool: return self._thread.is_alive() @@ -112,8 +124,8 @@ def start( ``reload`` makes PaperPi load the config file again (after a change in the web interface); ``text`` is the config file text PaperPi uses now. The Plugin Library's - example pictures are kept in ``state_dir`` and drawn for ``display`` (none without - them). + example pictures are kept in ``state_dir`` (without it there are none) and drawn for + ``display``, the screen's settings at the start. """ try: sock = _listen(settings.address, settings.port) @@ -132,7 +144,7 @@ def start( server = WebServer(Auth(config_file, settings), settings, sock, editor, pictures) server._thread.start() if display is not None: - pictures.use(Screen.of(display)) + server.use_display(display) return server diff --git a/src/paperpi/web/templates/library.html b/src/paperpi/web/templates/library.html index bfc8ac3..e57d931 100644 --- a/src/paperpi/web/templates/library.html +++ b/src/paperpi/web/templates/library.html @@ -8,8 +8,8 @@

Plugin Library

Active Plugins. The same plugin can be added more than once, with different settings (for example the weather for two places).

{% if shown.values()|select|first %} -

Each example is drawn at your screen's size with the plugin's sample data -and default settings, not real data.

+

Each example picture is drawn at your screen's size with the plugin's +sample data and default settings, so it doesn't show your own data.

{% endif %}
    {% for item in items %} diff --git a/src/paperpi/web/templates/picture.html b/src/paperpi/web/templates/picture.html index 4d6ab86..4771793 100644 --- a/src/paperpi/web/templates/picture.html +++ b/src/paperpi/web/templates/picture.html @@ -7,7 +7,7 @@ height="{{ picture.screen.height }}" alt="Example of {{ item.type }}, drawn with its sample data"> {%- elif picture.problem %} -

    No example picture: {{ picture.problem|sentence }}

    +

    No example picture. {{ picture.problem }}

    {%- else %}

    Drawing the example picture…

    diff --git a/tests/test_cli.py b/tests/test_cli.py index 9287f7b..877ffd6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -568,5 +568,6 @@ def test_run_starts_the_web_interface_unless_asked_not_to( # A change in the web interface reloads PaperPi; the web knows the text in use. ((_, kw),) = calls assert kw["text"] == text + assert kw["state_dir"] == tmp_path and kw["display"].type == "virtual" kw["reload"]() assert reloads == [1] diff --git a/tests/test_web_pictures.py b/tests/test_web_pictures.py index da40394..94d5a36 100644 --- a/tests/test_web_pictures.py +++ b/tests/test_web_pictures.py @@ -2,7 +2,10 @@ import html import itertools +import os import shutil +import socket +import threading import time from pathlib import Path @@ -13,8 +16,8 @@ from paperpi import config from paperpi.plugin import State -from paperpi.runner import PluginFailed -from paperpi.web import auth +from paperpi.runner import PluginFailed, PluginTimeout +from paperpi.web import auth, server from paperpi.web.app import create_app from paperpi.web.pictures import Pictures, Screen from paperpi.web.plugins import PluginEditor @@ -52,10 +55,20 @@ def until(check, seconds=5.0): time.sleep(0.01) -def started(folder, package, update, screen=SCREEN, wait=0.0): - pictures = Pictures(folder, update, package=package, wait=wait) - pictures.use(screen) - return pictures +@pytest.fixture +def make(tmp_path, package): + """Starts pictures in ``tmp_path/pics`` for the test package; stopped at the end.""" + made = [] + + def make(update, screen=SCREEN, wait=0.0): + pictures = Pictures(tmp_path / "pics", update, package=package, wait=wait) + pictures.use(screen) + made.append(pictures) + return pictures + + yield make + for pictures in made: + pictures.stop() def drawn(pictures, *types): @@ -63,77 +76,158 @@ def drawn(pictures, *types): return [pictures.shown(t) for t in types] -def test_each_plugin_is_drawn_once_with_its_sample_data(tmp_path, package): +def stale_gone(path): + return lambda: not path.exists() + + +def round_done(pictures): + """Wait until a whole new round has run: it deletes a picture that matches nothing.""" + for _ in range(2): # the first may be a round that had started already + stale = pictures.folder / "old-0000000000000000.png" + pictures.folder.mkdir(exist_ok=True) + stale.write_bytes(b"x") + pictures.use(pictures._screen) # e.g. a config reload + until(stale_gone(stale)) + + +def test_each_plugin_is_drawn_once_with_its_sample_data(tmp_path, make): update = FakeUpdate(State.READY, State.READY) - pictures = started(tmp_path / "pics", package, update) + pictures = make(update) fake, broken = drawn(pictures, "fake", "broken") assert fake.file.startswith("fake-") and fake.file.endswith(".png") and fake.screen == SCREEN image = Image.open(tmp_path / "pics" / fake.file) assert image.size == (300, 200) - assert "broken" in broken.problem and not broken.file + assert broken.problem.startswith("The plugin is broken:") and not broken.file [(plugin_type, context, sample, time_limit)] = update.calls # not default, not broken assert (plugin_type, sample, time_limit) == ("fake", True, 10.0) assert (context.width, context.height, context.mode) == (300, 200, SCREEN.mode) assert context.layout == "one" and context.settings.act == "ok" - pictures.use(SCREEN) # e.g. a config reload: nothing to draw again - time.sleep(0.1) + round_done(pictures) # nothing to draw again assert len(update.calls) == 1 and pictures.shown("fake") == fake assert sorted(p.name for p in (tmp_path / "pics").iterdir()) == [fake.file] - pictures.stop() -def test_a_changed_plugin_or_screen_is_drawn_again(tmp_path, package, monkeypatch): - update = FakeUpdate(*[State.READY] * 3) - pictures = started(tmp_path / "pics", package, update) +def test_a_changed_plugin_is_drawn_again(make, package): + update = FakeUpdate(State.READY, State.READY) + pictures = make(update) [first] = drawn(pictures, "fake") code = Path(__import__(package).__file__).parent / "fake" / "__init__.py" code.write_text(code.read_text() + "\n# changed\n") [second] = drawn(pictures, "fake") # opening the page notices the change - assert second.file != first.file + assert second.file != first.file and len(update.calls) == 2 + until(lambda: not (pictures.folder / first.file).exists()) + + +def test_another_screen_is_drawn_again(make): + update = FakeUpdate(State.READY, State.READY) + pictures = make(update) + [first] = drawn(pictures, "fake") other = Screen(200, 300, ScreenMode.bw()) pictures.use(other) - until(lambda: pictures.shown("fake").file not in ("", second.file)) - third = pictures.shown("fake") - assert third.screen == other and Image.open(tmp_path / "pics" / third.file).size == (200, 300) - until(lambda: [p.name for p in (tmp_path / "pics").iterdir()] == [third.file]) - assert len(update.calls) == 3 - pictures.stop() + until(lambda: pictures.shown("fake").file not in ("", first.file)) + second = pictures.shown("fake") + assert second.screen == other + assert Image.open(pictures.folder / second.file).size == (200, 300) + until(lambda: [p.name for p in pictures.folder.iterdir()] == [second.file]) + + +def test_only_old_pictures_and_parts_are_deleted(make): + pictures = make(FakeUpdate(State.READY), wait=60) + pictures.folder.mkdir() + for name in ("notes.txt", "old-0123456789abcdef.png", ".old-0123456789abcdef.png.part"): + (pictures.folder / name).write_text("x") + [fake] = drawn(pictures, "fake") + round_done(pictures) + assert sorted(p.name for p in pictures.folder.iterdir()) == [fake.file, "notes.txt"] -def test_a_picture_that_fails_says_why_and_is_not_tried_again(tmp_path, package): +def test_a_picture_that_fails_says_why_and_is_not_tried_again(make): update = FakeUpdate(PluginFailed("fake", "ValueError: no font")) - pictures = started(tmp_path / "pics", package, update) + pictures = make(update) [fake] = drawn(pictures, "fake") assert fake.problem == "Its sample data could not be drawn: ValueError: no font" - pictures.use(SCREEN) - time.sleep(0.1) + round_done(pictures) assert len(update.calls) == 1 and pictures.shown("fake").problem == fake.problem - pictures.stop() -def test_nothing_to_show_is_a_problem_too(tmp_path, package): - pictures = started(tmp_path / "pics", package, FakeUpdate(State.NOTHING)) +def test_a_slow_plugin_is_tried_three_times(make): + slow = PluginTimeout("fake", "no result within 10 s; stopped") + update = FakeUpdate(slow, slow, slow) + pictures = make(update) + [fake] = drawn(pictures, "fake") # each look at the page tries again + assert fake.problem == "Its sample data could not be drawn: no result within 10 s; stopped" + assert len(update.calls) == 3 + update = FakeUpdate(slow, State.READY) + assert drawn(make(update), "fake")[0].file # the second try worked + + +def test_any_error_is_shown_and_the_others_are_still_drawn(make, package): + root = Path(__import__(package).__file__).parent + shutil.copytree(root / "fake", root / "zfake") + text = (root / "zfake" / "__init__.py").read_text() + (root / "zfake" / "__init__.py").write_text(text.replace('type="fake"', 'type="zfake"')) + pictures = make(FakeUpdate(RuntimeError("no process"), State.READY)) + fake, zfake = drawn(pictures, "fake", "zfake") + assert fake.problem == "It could not be drawn: no process" and zfake.file + + +def test_nothing_to_show_is_a_problem_too(make): + pictures = make(FakeUpdate(State.NOTHING)) assert drawn(pictures, "fake")[0].problem == "Its sample data gives no picture." - pictures.stop() -def test_drawing_waits_after_the_start_unless_the_page_is_opened(tmp_path, package): +@pytest.mark.skipif(os.geteuid() == 0, reason="root may write anywhere") +def test_a_folder_that_cant_be_written_says_so(tmp_path, make): + (tmp_path / "pics").mkdir(mode=0o500) + try: + pictures = make(FakeUpdate(State.READY)) + [fake] = drawn(pictures, "fake") + assert fake.problem == "It could not be saved: Permission denied." + assert list(pictures.folder.iterdir()) == [] # no part left + finally: + (tmp_path / "pics").chmod(0o700) + (tmp_path / "pics").rmdir() + (tmp_path / "pics").write_text("not a folder") + pictures = make(FakeUpdate(State.READY)) + until(lambda: pictures.shown("fake").problem.startswith("Pictures can't be saved:")) + + +def test_a_plugin_whose_files_cant_be_read_says_so(make, monkeypatch): + pictures = make(FakeUpdate(), wait=60) + + def unreadable(plugin_type, screen): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(pictures, "file_name", unreadable) + assert pictures.shown("fake").problem == "Its files can't be read: Permission denied." + + +def test_drawing_waits_after_the_start_unless_the_page_is_opened(make): update = FakeUpdate() - pictures = started(tmp_path / "pics", package, update, wait=60) + pictures = make(update, wait=60) time.sleep(0.1) assert update.calls == [] assert pictures.shown("fake").waiting # the page is opened: start now until(lambda: pictures.shown("fake").file) - pictures.stop() -def test_stopped_pictures_draw_nothing(tmp_path, package): - update = FakeUpdate() - pictures = started(tmp_path / "pics", package, update, wait=60) - pictures.stop() - pictures.shown("fake") +def test_stop_ends_drawing(make, package): + root = Path(__import__(package).__file__).parent + shutil.copytree(root / "fake", root / "zfake") + text = (root / "zfake" / "__init__.py").read_text() + (root / "zfake" / "__init__.py").write_text(text.replace('type="fake"', 'type="zfake"')) + update = FakeUpdate(State.READY, State.READY) + pictures = make(update) + real = update.__call__ + + def stop_after_first(*args, **kwargs): + pictures.stop() + return real(*args, **kwargs) + + pictures._update = stop_after_first + until(lambda: (pictures.folder / pictures.file_name("fake", SCREEN)).exists()) time.sleep(0.1) - assert update.calls == [] + assert len(update.calls) == 1 and not pictures._thread.is_alive() def test_no_folder_means_no_pictures(package): @@ -142,18 +236,50 @@ def test_no_folder_means_no_pictures(package): assert pictures.shown("fake") is None and pictures.path("fake-0123456789abcdef.png") is None +def test_the_screen_is_turned_with_the_display(): + display = config.DisplaySettings(type="virtual", width=300, height=200, rotation=90) + assert Screen.of(display) == Screen(200, 300, display.screen_mode) + + +def test_a_reload_keeps_the_screen_size_until_the_next_start(tmp_path): + used = [] + pictures = Pictures(tmp_path / "pics") + pictures.use = used.append + cfg = tmp_path / "paperpi.toml" + cfg.write_text(CONFIG) + settings = config.WebSettings() + editor = PluginEditor(cfg) + web = server.WebServer(auth.Auth(cfg, settings), settings, socket.socket(), editor, pictures) + try: + start = config.DisplaySettings(type="virtual", width=300, height=200) + web.use_display(start) + # As in the scheduler: size and mode wait for the next start, rotation does not. + web.use_display(start.model_copy(update={"width": 800, "mode": "bw", "rotation": 90})) + assert used == [Screen(300, 200, start.screen_mode), Screen(200, 300, start.screen_mode)] + finally: + web._socket.close() + + @pytest.mark.parametrize( "name", ["../paperpi.toml", "fake-0123456789abcdef.txt", "fake-0123.png", "-0123456789abcdef.png", - "a/b-0123456789abcdef.png", "fake-0123456789ABCDEF.png", ".fake-0123456789abcdef.png.part"], + "fake-0123456789ABCDEF.png", ".fake-0123456789abcdef.png.part"], ) # fmt: skip def test_only_picture_files_are_given_out(tmp_path, name): folder = tmp_path / "pics" - (folder / "a").mkdir(parents=True) + folder.mkdir() (folder / name).write_bytes(b"x") # even when the file is there assert Pictures(folder).path(name) is None +def test_links_are_not_given_out(tmp_path): + folder = tmp_path / "pics" + folder.mkdir() + (tmp_path / "paperpi.toml").write_text("secret") + (folder / "fake-0123456789abcdef.png").symlink_to(tmp_path / "paperpi.toml") + assert Pictures(folder).path("fake-0123456789abcdef.png") is None + + # The Library page. @@ -168,14 +294,22 @@ def make_client(tmp_path, pictures): @pytest.fixture def ready_pictures(tmp_path): - """Real plugins, one picture saved already (as if drawn), drawing not started yet.""" - pictures = Pictures(tmp_path / "pics", FakeUpdate(), wait=60) + """Real plugins, one picture saved already (as if drawn); drawing the others waits + until the test ends, so they stay "being drawn".""" + release = threading.Event() + + def update(plugin_type, context, *, sample, time_limit): + release.wait(10) + raise PluginFailed(plugin_type, "test ended") + + pictures = Pictures(tmp_path / "pics", update, wait=60) pictures.use(SCREEN) name = pictures.file_name("basic_clock", SCREEN) (tmp_path / "pics").mkdir() Image.new("L", (300, 200), 255).save(tmp_path / "pics" / name) yield pictures, name pictures.stop() + release.set() def test_the_library_shows_drawn_pictures_and_waits_for_the_others(tmp_path, ready_pictures): @@ -183,11 +317,11 @@ def test_the_library_shows_drawn_pictures_and_waits_for_the_others(tmp_path, rea client = make_client(tmp_path, pictures) page = html.unescape(client.get("/library").text) assert f' Date: Sat, 10 Oct 2026 11:36:03 +0200 Subject: [PATCH 3/3] Library: name and picture open Add; pictures in 256 grays; previews use the running screen The Library has no separate Add link: the plugin's name and its picture open the Add page. Example pictures are drawn in 256 shades of gray (color screens keep their colors), so they are easy to see on a phone. Previews draw for the screen as it runs: a new size, mode or type waits for the next start, as in the scheduler. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- docs/decisions/web-interface.md | 4 ++-- src/paperpi/web/pictures.py | 13 +++++++++---- src/paperpi/web/plugins.py | 9 +++++++-- src/paperpi/web/server.py | 8 ++++---- src/paperpi/web/templates/library.html | 12 ++++++++---- src/paperpi/web/templates/picture.html | 5 +++-- tests/test_web_pictures.py | 27 ++++++++++++++++++++------ tests/test_web_preview.py | 15 +++++++++++++- 9 files changed, 69 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f7abdef..5830201 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ uv run paperpi run --config paperpi.toml --out screen/ --state-dir state/ --heal ### The web interface -`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi, with an example picture drawn at your screen's size from the plugin's sample data, in the background: it starts a minute after PaperPi starts or when you open the page, and until then the page says "Drawing the example picture…"; add one at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. +`paperpi run` also starts the web interface, on port 8080. Open it by the Pi's IP address on a phone or computer on the same home network, for example `http://192.168.1.20:8080` (your router's list of devices shows the Pi's address; on the Pi, `hostname -I` prints it). Names such as `paperpi.local` are refused for now, for safety (see [docs/decisions/web-interface.md](docs/decisions/web-interface.md)). It has three pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one, open its settings), **Plugin Library** (every plugin that comes with PaperPi, with an example picture drawn at your screen's size from the plugin's sample data (in 256 shades of gray, or in color for a color screen), in the background: it starts a minute after PaperPi starts or when you open the page, and until then the page says "Drawing the example picture…"; choose a name or picture to add it at the end of the list) and each plugin's **Settings** (every setting with its help text and default; errors are shown next to the field; **Preview** draws the plugin with the settings in the form before you save them, at your screen's size, without touching the screen; if the real data can't be drawn, it shows sample data and says why). A change there is saved in the config file and applies at once. Comments in the file stay, except the comments of a plugin you remove and a comment written after a setting, on the same line, when the web interface changes that setting. If the config file changed since the page was opened, is written in a way the web interface can't change safely, or has a problem that stops PaperPi from using it, nothing is saved and the page says so. A plugin that needs settings (such as `met_no`, which needs a place and your email address) is added switched off, and its Settings page opens to fill them in. - **First visit:** the first person to open it sets the web password (at least 8 characters). The browser then stays logged in for a year, until **Log out**. Log out only logs out that browser; a new password logs out every browser. - **Forgotten password:** you need access to the Pi itself (a keyboard and screen, or SSH). diff --git a/docs/decisions/web-interface.md b/docs/decisions/web-interface.md index 29df80b..4657d37 100644 --- a/docs/decisions/web-interface.md +++ b/docs/decisions/web-interface.md @@ -62,14 +62,14 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s - **Home page:** a copy of what is on the e-paper screen now, with the plugin's name and when it was drawn. It updates by itself when the screen changes. - **Plugin settings page:** a **Preview** button draws the plugin with the settings in the form, before saving, in a separate process, as normal updates are drawn. The real screen is not touched. If the plugin's data source can't be reached, the preview uses the plugin's sample data and says so. - *Update (M5 part 3b-1, agreed with txoof on 2026-10-09):* - - The preview draws the block as Save would write it (the form's values applied to a copy of the file text), at the screen's size and for the screen's type from `[display]`. It runs in its own process with a time limit, as normal updates do, in a new, empty storage folder that is deleted afterwards, so it can't change the plugin's saved files. + - The preview draws the block as Save would write it (the form's values applied to a copy of the file text), at the screen's size and for the screen's type from `[display]`, as the screen runs now (a change of type, model, size or mode applies at the next start, also for previews; rotation and `color` apply at once). It runs in its own process with a time limit, as normal updates do, in a new, empty storage folder that is deleted afterwards, so it can't change the plugin's saved files. - Real data may take the plugin's own time limit, but at most 45 seconds. When it fails, takes longer, has nothing to show right now, or a required setting is still empty, the preview draws the plugin's sample data (at most 10 seconds) and says why. - One preview is drawn at a time; a second one meanwhile is asked to wait. - The picture is sent inside the page (a `data:` address), so it is never saved as a file. The pages allow such pictures (`img-src 'self' data:`). - For later plugins: an empty secret field (such as an API key) keeps the saved secret, also in a preview. A plugin that has both a secret and a web address the user can change must not send the saved secret to a changed address: then the secret must be typed again (in a preview and when saving), or the plugin allows only its own servers. Network errors in the preview should then say only "could not reach the server", so the preview can't be used to look around the home network. No plugin has such an address yet (checked in the security review of part 3b-1); this matters for e.g. the Dutch public transport plugin. - There is no "send to display" button on the settings page. A **Show on screen now** button, which starts a saved plugin's turn at once, comes with the home page in part 4. - **Plugin Library:** each plugin has an example picture, drawn with its sample data. *Agreed with txoof on 2026-10-10 (M5 part 3b-2):* - - The pictures are drawn ahead and saved in `library-pictures/` in the state folder, so the page opens at once. They are drawn at the screen's size and for the screen's type from `[display]`, with the plugin's default settings and first layout, one at a time, each in its own process with the 10-second sample time limit. Drawing starts a minute after PaperPi starts (so the first screen update goes first), or at once when the Library page is opened or the config file is loaded again. A change of the screen's type, model, size or mode applies at the next start, as for the screen itself. + - The plugin's name and its picture both open the Add page (no separate Add link). The pictures are drawn ahead and saved in `library-pictures/` in the state folder, so the page opens at once. They are drawn at the screen's size from `[display]`, in the best gray (256 shades) so they are easy to see on a phone, also for a black-and-white or 4-gray screen; a color screen keeps its colors (txoof, 2026-10-10). They use the plugin's default settings and first layout, one at a time, each in its own process with the 10-second sample time limit. Drawing starts a minute after PaperPi starts (so the first screen update goes first), or at once when the Library page is opened or the config file is loaded again. A change of the screen's type, model, size or mode applies at the next start, as for the screen itself. - Each file name holds a fingerprint: a short code made from the name, size and time of last change of every file in the plugin's folder, the screen's width, height and type, and the PaperPi and epdlib versions. A new or changed plugin, or another screen, gives a new name, so its picture is drawn again; pictures that match nothing any more are deleted. The files' contents are not read, as that is too slow for the 19 MB of moon_phase images on a Pi 3. Only files in the plugin's own folder count; a change to PaperPi's shared code counts only through the PaperPi version. The browser may keep a picture for a day; a new picture has a new file name. - While a picture is missing, the page shows "Drawing the example picture…" and asks again every 3 seconds (htmx) until it is there. A plugin that takes longer than 10 seconds (the Pi may just be busy) is tried up to 3 times. Any other picture that can't be drawn says why, and is not tried again until the plugin, the screen or PaperPi changes (or PaperPi starts again), so an open Library page can't keep the Pi busy. If the folder can't be written, every plugin says so. - A plugin whose code is changed while PaperPi runs gets a new picture (each picture is drawn in a new process), but the Library's list and a plugin that was broken at the start only change after a restart, because the web interface loads each plugin's code once. diff --git a/src/paperpi/web/pictures.py b/src/paperpi/web/pictures.py index 59e68bb..0289498 100644 --- a/src/paperpi/web/pictures.py +++ b/src/paperpi/web/pictures.py @@ -1,8 +1,9 @@ """Example pictures for the Plugin Library: each plugin drawn with its sample data. See ``docs/decisions/web-interface.md``. The pictures are drawn ahead, in the background, -at the screen's size and for the screen's type, and saved in ``library-pictures/`` in the -state folder, so the Library page opens at once (agreed with txoof, M5 part 3b-2). +at the screen's size (in 256 shades of gray, or in color for a color screen), and saved in +``library-pictures/`` in the state folder, so the Library page opens at once (agreed with +txoof, M5 part 3b-2). A picture's file name holds a fingerprint: a short code made from the plugin's files (the name, size and time of last change of each file in its folder, so a change to any of them @@ -61,8 +62,12 @@ class Screen: @classmethod def of(cls, display: config.DisplaySettings) -> Screen: - """The screen plugins draw for with ``display`` (turned when it is rotated).""" - return cls(*display.layout_size, display.screen_mode) + """What the pictures are drawn for with ``display``: its size (turned when it is + rotated), in the best gray (256 shades) so the examples are easy to see on a phone, + also for a black-and-white screen (agreed with txoof, 2026-10-10). A color screen + keeps its colors.""" + mode = display.screen_mode + return cls(*display.layout_size, mode if mode.has_color else ScreenMode.gray(256)) @dataclass(frozen=True) diff --git a/src/paperpi/web/plugins.py b/src/paperpi/web/plugins.py index 4c7c0c2..42f2eeb 100644 --- a/src/paperpi/web/plugins.py +++ b/src/paperpi/web/plugins.py @@ -105,6 +105,9 @@ def __init__(self, path: Path, reload: Callable[[], None] | None = None): self._reload = reload self._applied: str | None = None """The file text PaperPi uses now (``None``: not known).""" + self.display: config.DisplaySettings | None = None + """The ``[display]`` settings the screen uses now (``None``: not known, then + previews use the file's).""" def loaded(self, text: str) -> None: """PaperPi loaded the config file ``text`` (at the start or a reload).""" @@ -190,8 +193,10 @@ def preview_job( "This plugin can't be drawn. Its settings in the config file have a problem: " f"{problems or 'unknown'}" ) - width, height = checked.display.layout_size - return preview.Job(found, width, height, checked.display.screen_mode) + # The screen as it runs now: a new size, mode or type waits for the next start. + display = self.display or checked.display + width, height = display.layout_size + return preview.Job(found, width, height, display.screen_mode) def row(self, index: int, plugin_id: str) -> Row: """The block at ``index``, if it still has the ID ``plugin_id``.""" diff --git a/src/paperpi/web/server.py b/src/paperpi/web/server.py index 7e9db87..dbb7f91 100644 --- a/src/paperpi/web/server.py +++ b/src/paperpi/web/server.py @@ -83,13 +83,13 @@ def use(self, loaded: config.Config) -> None: log.warning("[web] %s: changes apply at the next start of PaperPi", ", ".join(changed)) def use_display(self, display: config.DisplaySettings) -> None: - """Draw the Library pictures for ``display``. As in the scheduler, a change of the - screen's type, model, size or mode applies only at the next start, so the pictures - keep the values the screen started with.""" + """Draw previews and the Library pictures for ``display``. As in the scheduler, a + change of the screen's type, model, size or mode applies only at the next start, so + they keep the values the screen started with.""" if self.display is not None: started = {key: getattr(self.display, key) for key in config.NEXT_START} display = display.model_copy(update=started) - self.display = display + self.display = self.editor.display = display self.pictures.use(Screen.of(display)) @property diff --git a/src/paperpi/web/templates/library.html b/src/paperpi/web/templates/library.html index e57d931..171a968 100644 --- a/src/paperpi/web/templates/library.html +++ b/src/paperpi/web/templates/library.html @@ -4,19 +4,23 @@ {% block main %}

    Plugin Library

    {% if problem %}

    {{ problem }}

    {% endif %} -

    Every plugin that comes with PaperPi. Choose one to add it at the end of +

    Every plugin that comes with PaperPi. Choose a name or picture to add it at the end of Active Plugins. The same plugin can be added more than once, with different settings (for example the weather for two places).

    {% if shown.values()|select|first %}

    Each example picture is drawn at your screen's size with the plugin's -sample data and default settings, so it doesn't show your own data.

    +sample data and default settings, so it doesn't show your own data. Black-and-white and gray +screens show fewer shades than the example.

    {% endif %}
      {% for item in items %}
    • + {% if item.plugin %} + {{ item.type }}: {{ item.description }} + {% with picture = shown[item.type] %}{% include "picture.html" %}{% endwith %} + {% else %} {{ item.type }}: {{ item.description }} - {% if item.plugin %}Add{% endif %} - {% if item.plugin %}{% with picture = shown[item.type] %}{% include "picture.html" %}{% endwith %}{% endif %} + {% endif %}
    • {% endfor %}
    diff --git a/src/paperpi/web/templates/picture.html b/src/paperpi/web/templates/picture.html index 4771793..6b304c8 100644 --- a/src/paperpi/web/templates/picture.html +++ b/src/paperpi/web/templates/picture.html @@ -3,8 +3,9 @@ {% if picture is none %} {%- elif picture.file %}
    - Example of {{ item.type }}, drawn with its sample data + Add {{ item.type }} (example drawn with its sample data)
    {%- elif picture.problem %}

    No example picture. {{ picture.problem }}

    diff --git a/tests/test_web_pictures.py b/tests/test_web_pictures.py index 94d5a36..0969f0b 100644 --- a/tests/test_web_pictures.py +++ b/tests/test_web_pictures.py @@ -95,8 +95,8 @@ def test_each_plugin_is_drawn_once_with_its_sample_data(tmp_path, make): pictures = make(update) fake, broken = drawn(pictures, "fake", "broken") assert fake.file.startswith("fake-") and fake.file.endswith(".png") and fake.screen == SCREEN - image = Image.open(tmp_path / "pics" / fake.file) - assert image.size == (300, 200) + with Image.open(tmp_path / "pics" / fake.file) as image: + assert image.size == (300, 200) assert broken.problem.startswith("The plugin is broken:") and not broken.file [(plugin_type, context, sample, time_limit)] = update.calls # not default, not broken assert (plugin_type, sample, time_limit) == ("fake", True, 10.0) @@ -127,7 +127,8 @@ def test_another_screen_is_drawn_again(make): until(lambda: pictures.shown("fake").file not in ("", first.file)) second = pictures.shown("fake") assert second.screen == other - assert Image.open(pictures.folder / second.file).size == (200, 300) + with Image.open(pictures.folder / second.file) as image: + assert image.size == (200, 300) until(lambda: [p.name for p in pictures.folder.iterdir()] == [second.file]) @@ -236,9 +237,18 @@ def test_no_folder_means_no_pictures(package): assert pictures.shown("fake") is None and pictures.path("fake-0123456789abcdef.png") is None +@pytest.mark.parametrize( + ("mode", "drawn_in"), + [("bw", ScreenMode.gray(256)), ("gray4", ScreenMode.gray(256)), ("rgb", ScreenMode.rgb())], +) +def test_pictures_are_in_the_best_gray_or_in_color(mode, drawn_in): + display = config.DisplaySettings(type="virtual", width=300, height=200, mode=mode) + assert Screen.of(display) == Screen(300, 200, drawn_in) + + def test_the_screen_is_turned_with_the_display(): display = config.DisplaySettings(type="virtual", width=300, height=200, rotation=90) - assert Screen.of(display) == Screen(200, 300, display.screen_mode) + assert Screen.of(display) == Screen(200, 300, ScreenMode.gray(256)) def test_a_reload_keeps_the_screen_size_until_the_next_start(tmp_path): @@ -255,7 +265,9 @@ def test_a_reload_keeps_the_screen_size_until_the_next_start(tmp_path): web.use_display(start) # As in the scheduler: size and mode wait for the next start, rotation does not. web.use_display(start.model_copy(update={"width": 800, "mode": "bw", "rotation": 90})) - assert used == [Screen(300, 200, start.screen_mode), Screen(200, 300, start.screen_mode)] + gray = ScreenMode.gray(256) + assert used == [Screen(300, 200, gray), Screen(200, 300, gray)] + assert editor.display.width == 300 and editor.display.rotation == 90 # for previews finally: web._socket.close() @@ -316,7 +328,10 @@ def test_the_library_shows_drawn_pictures_and_waits_for_the_others(tmp_path, rea pictures, name = ready_pictures client = make_client(tmp_path, pictures) page = html.unescape(client.get("/library").text) - assert f'basic_clock' in page + assert ">Add<" not in page assert "drawn at your screen's size with the plugin's\nsample data" in page assert 'hx-get="/library/word_clock/picture"' in page # not drawn yet picture = client.get(f"/library/pictures/{name}") diff --git a/tests/test_web_preview.py b/tests/test_web_preview.py index 1559ff8..fb3ec03 100644 --- a/tests/test_web_preview.py +++ b/tests/test_web_preview.py @@ -58,7 +58,9 @@ def make_client(cfg, reloads, update): editor.loaded(CONFIG) paperpi_auth = auth.Auth(cfg, config.WebSettings(login=False)) app = create_app(paperpi_auth, editor, Previewer(update)) - return TestClient(app, follow_redirects=False, base_url=PI) + client = TestClient(app, follow_redirects=False, base_url=PI) + client.app_editor = editor + return client def preview(client, index, plugin_id, **form): @@ -191,6 +193,17 @@ def test_the_screen_size_and_type_come_from_the_display_settings(cfg, reloads): assert picture(page).size == (480, 800) +def test_a_new_screen_size_waits_for_the_next_start(cfg, reloads): + """Previews draw for the screen as it runs: a new size or mode applies at the next start.""" + update = FakeUpdate() + client = make_client(cfg, reloads, update) + client.app_editor.display = config.DisplaySettings(type="virtual", width=300, height=200) + cfg.write_text(CONFIG.replace('type = "virtual"', 'type = "virtual"\nwidth = 800\nmode = "bw"')) + preview(client, 0, "Clock") + context = update.calls[0][1] + assert (context.width, context.height, context.mode) == (300, 200, ScreenMode.gray(16)) + + def test_a_config_file_paperpi_cant_use_is_not_drawn(cfg, reloads): cfg.write_text(CONFIG.replace('type = "virtual"', 'type = "nope"')) update = FakeUpdate()