Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <process id>`).
- 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 <process id>`. 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 (`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; 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).
Expand Down
9 changes: 7 additions & 2 deletions docs/decisions/web-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -62,12 +62,17 @@ 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 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.

### Other pages

Expand Down
2 changes: 2 additions & 0 deletions src/paperpi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
7 changes: 6 additions & 1 deletion src/paperpi/limits.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@
#: 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
#: 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

Expand Down
48 changes: 39 additions & 9 deletions src/paperpi/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
in the file, counted from 0, and the plugin's id to check it is
still there); ``/plugins/<n>/preview`` draws the settings form's
values without saving them (part of the settings page)
``/library`` Plugin Library: every plugin type; ``/library/<type>`` adds one and
opens its settings
``/library`` Plugin Library: every plugin type with an example picture
(``/library/pictures/<file>``; ``/library/<type>/picture`` is the
picture's part of the page, asked for again until it is drawn);
``/library/<type>`` 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)
================= ==============================================================
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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")
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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": "private, 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)

Expand Down
Loading
Loading