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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)). For now it has the log-in and an empty home page; the pages for plugins and settings follow (issue #238).
`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 two pages for plugins: **Active Plugins** (switch plugins on or off, move them up or down, remove one) and **Plugin Library** (every plugin that comes with PaperPi; add one at the end of the list). 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 on the same line as `enabled =`. 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; until the settings page follows (issue #238), fill those in in the config file.

- **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
2 changes: 1 addition & 1 deletion docs/decisions/plugin-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ The plugin never talks to the screen. Only the scheduler does.

For sample images and tests, `fetch` is skipped and the sample data goes straight to `draw`. How to write a plugin: `docs/writing-plugins.md`.

**Update 2026-10-07 (M5 part 2a, issue #238, agreed with txoof):** a plugin can mark a setting as **required** with `setting(..., required=True)`, for settings it can't work without, such as an API key. The first ones are `lat`, `lon` and `email` of `met_no` and `moon_phase`: met.no's terms of service ask for a real email address, and a user should not start these plugins without giving one. A plugin that is missing a required setting can be in the config file and switched on, but it is **not shown and not counted as broken**: the config check gives a warning naming the missing settings, `paperpi list` shows "needs email" in its "on" column, and the web interface (part 2b) shows "Needs settings" in place of the on switch. The example config no longer fills in a made-up email address. `setting` is the one standard place for a setting's own options; the input helpers of `web-interface.md` (such as `location`, which looks up latitude and longitude; part 3c) will be options of `setting` too.
**Update 2026-10-07 (M5 part 2a, issue #238, agreed with txoof):** a plugin can mark a setting as **required** with `setting(..., required=True)`, for settings it can't work without, such as an API key. The first ones are `lat`, `lon` and `email` of `met_no` and `moon_phase`: met.no's terms of service ask for a real email address, and a user should not start these plugins without giving one. A plugin that is missing a required setting can be in the config file and switched on, but it is **not shown and not counted as broken**: the config check gives a warning naming the missing settings, `paperpi list` shows "needs email" in its "on" column, and the web interface's Active Plugins page shows "Needs settings: ..." and its Switch on button can't be used until they are filled in. The example config no longer fills in a made-up email address. `setting` is the one standard place for a setting's own options; the input helpers of `web-interface.md` (such as `location`, which looks up latitude and longitude; part 3c) will be options of `setting` too.

### How a plugin is run

Expand Down
10 changes: 10 additions & 0 deletions docs/decisions/web-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,16 @@ In v2 the web interface is the main way to set up and change PaperPi: plugins, s
- Pages are built on the Pi. htmx updates parts of a page, such as the screen preview and the list of warnings.
- A page that needs dragging or resizing gets a small JavaScript library made for that, loaded as a plain file with no build step. The first case will be the dashboard editor (see "Later").

### Plugin list pages

*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") and adds the block at the end of Active Plugins. Sample pictures follow in part 3b.
- 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.
- 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 `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.)
- A change names its block by place and name. If the block was moved or renamed by hand since the page was shown, nothing is changed and the page says the file changed meanwhile. A file the web interface can't change safely (for example a `[[plugin]]` line written in an unusual way) is never saved wrongly: every change is read back and compared before saving. A change is also refused while the file has a problem that stops PaperPi from using it (PaperPi then runs on the last good copy, so the change would not apply), and when the file would become larger than PaperPi reads.

### Plugin settings forms

- One page handles every plugin. It reads the plugin's settings description (see `plugin-interface.md`) and builds the form from it: number field, dropdown, password field for API keys, and so on.
Expand Down
3 changes: 2 additions & 1 deletion docs/writing-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ Each setting is a field of the plugin's `Settings` class, with a type, a default
- Every setting needs a default.
- Don't use the names of the shared settings, which every `[[plugin]]` block already has: `name`, `type`, `enabled`, `level`, `display_time`, `refresh`, `time_limit`, `layout`, `alert_reminder`, `alert_max_time`.
- Use `pydantic.SecretStr` as the type for API keys and passwords. PaperPi then never shows their values in error messages or logs.
- A setting the user must fill in before the plugin can work (a place, an email address, an API key) is made with `paperpi.plugin.setting(..., required=True)` instead of `Field`. It takes the same arguments as `Field`. Its default must be "not set": `None`, empty text or an empty `SecretStr` (text of only spaces also counts as not set). Until every required setting is filled in, the plugin is not shown and not counted as broken; the config check and `paperpi list` (and from M5 part 2b the web interface) say which settings are missing. The example block marks them "(required)". `setting` is also where later options for a single setting are added, such as a web interface helper that looks up latitude and longitude (M5 part 3c).
- A setting the user must fill in before the plugin can work (a place, an email address, an API key) is made with `paperpi.plugin.setting(..., required=True)` instead of `Field`. It takes the same arguments as `Field`. Its default must be "not set": `None`, empty text or an empty `SecretStr` (text of only spaces also counts as not set). Until every required setting is filled in, the plugin is not shown and not counted as broken; the config check, `paperpi list` and the web interface's Active Plugins page say which settings are missing. The example block marks them "(required)". `setting` is also where later options for a single setting are added, such as a web interface helper that looks up latitude and longitude (M5 part 3c).
- The web interface shows the plugin's `description` in its Plugin Library, and the help text of each required setting on the page that adds the plugin. Write both for someone at home who is not a programmer.

```python
from paperpi.plugin import PluginSettings, setting
Expand Down
9 changes: 7 additions & 2 deletions src/paperpi/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ def _run(args: argparse.Namespace) -> int:
def load() -> config.Config:
loaded = config.load(args.config, state_dir=args.state_dir)
if web is not None and not loaded.from_last_good:
web.use(loaded.web)
web.use(loaded)
return loaded

try:
Expand Down Expand Up @@ -325,7 +325,12 @@ def load() -> config.Config:
# Imported here: the web packages take a moment to load, and no other command needs them.
from .web import server

web = server.start(args.config, loaded.web)
web = server.start(
args.config,
loaded.web,
reload=scheduler.reload,
text=None if loaded.from_last_good else loaded.text,
)
if web is not None:
print(f"web interface on port {web.port}")
try:
Expand Down
6 changes: 5 additions & 1 deletion src/paperpi/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ class Config:
from_last_good: bool = False
"""True when the file was wrong and the last good copy is used instead."""
web: WebSettings = field(default_factory=WebSettings)
text: str = ""
"""The file text it was read from (with ``from_last_good``: the last good copy's)."""

@property
def errors(self) -> list[Problem]:
Expand Down Expand Up @@ -439,7 +441,9 @@ def parse(text: str, source: str = "config") -> Config:
# E.g. lists nested thousands deep, or a number with thousands of digits.
problem = f"not valid TOML: {type(error).__name__}: {str(error)[:200]}"
raise ConfigError([Problem("error", problem, source)]) from None
return _Checker(text, source).check(data)
config = _Checker(text, source).check(data)
config.text = text
return config


def _log_problems(problems: list[Problem]) -> None:
Expand Down
2 changes: 1 addition & 1 deletion src/paperpi/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def setting(default: Any, *, required: bool = False, **field: Any) -> Any:
``required``: the plugin can't work until the user fills it in (a place, an email
address, an API key). Its default must be "not set" (see :func:`is_set`). A plugin
with a required setting that is not set is not shown; the config check and
``paperpi list`` (and from M5 part 2b the web interface) say which settings it needs.
``paperpi list`` and the web interface's Active Plugins page say which settings it needs.
"""
extra = field.pop("json_schema_extra", None) or {}
if not isinstance(extra, dict):
Expand Down
115 changes: 109 additions & 6 deletions src/paperpi/web/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
``/setup`` the first visitor sets the password (only while none is set)
``/login`` log in; also says how to reset a forgotten password
``/logout`` log out (a button on every page)
``/plugins`` Active Plugins: the plugins in the config file; switch on or off, move
up or down, remove (``/plugins/<n>/...``, n = place in the file,
counted from 0)
``/library`` Plugin Library: every plugin type; ``/library/<type>`` adds one
``/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 @@ -23,33 +27,38 @@
import ipaddress
from collections.abc import Awaitable, Callable
from pathlib import Path
from urllib.parse import urlsplit
from urllib.parse import urlencode, urlsplit

from fastapi import FastAPI, Request
from fastapi import FastAPI, Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.concurrency import run_in_threadpool

from .. import __version__
from .auth import COOKIE_SECONDS, Auth, new_cookie
from .config_file import EditError
from .password import PasswordError, password_problem
from .plugins import PluginEditor, library, library_item

COOKIE = "paperpi_login"
_HERE = Path(__file__).parent
#: Pages that work without a log-in (and everything in ``/static/``).
_OPEN = ("/setup", "/login")


def create_app(auth: Auth) -> FastAPI:
"""The web interface, using ``auth`` for the password and log-in."""
def create_app(auth: Auth, editor: PluginEditor | None = None) -> FastAPI:
"""The web interface, using ``auth`` for the password and log-in and ``editor`` to
change the plugins in the config file."""
editor = editor or PluginEditor(auth.config_file)
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")
templates.env.globals.update(version=__version__, auth=auth)
templates.env.filters["sentence"] = lambda text: text[:1].upper() + text[1:]

def page(request: Request, name: str, status: int = 200, **values) -> HTMLResponse:
return templates.TemplateResponse(request, name, values, status_code=status)
def page(request: Request, template: str, status: int = 200, **values) -> HTMLResponse:
return templates.TemplateResponse(request, template, values, status_code=status)

def logged_in(request: Request, stored: str) -> RedirectResponse:
response = RedirectResponse("/", status_code=303)
Expand Down Expand Up @@ -137,6 +146,100 @@ async def login(request: Request):
return logged_in(request, stored)
return page(request, "login.html", 401, problem="Wrong password.")

def plugin_list(request: Request, status: int = 200, problem: str | None = None):
try:
found = editor.plugin_list()
except EditError as error:
rows, problems = [], [str(error)]
status = 500 if status == 200 else status
else:
rows, problems = found.rows, found.problems
done = request.query_params.get("done")
# The name in the address is only shown when it is in the list: a link can't put
# other text on the page.
name = request.query_params.get("name")
return page(
request,
"plugins.html",
status,
rows=rows,
problems=[p for p in dict.fromkeys(problems) if p != problem],
problem=problem,
done=done if done in ("saved", "added", "removed") else None,
name=name if any(row.name == name for row in rows) else None,
hand_edits=request.query_params.get("hand") == "1",
config_file=auth.config_file,
)

def saved(done: str, hand_edits: bool, name: str = "") -> RedirectResponse:
values = {"done": done} | ({"name": name} if name else {})
query = urlencode(values | ({"hand": "1"} if hand_edits else {}))
return RedirectResponse(f"/plugins?{query}", status_code=303)

@app.get("/plugins", response_class=HTMLResponse)
def plugins_page(request: Request):
return plugin_list(request)

@app.post("/plugins/{index}/move", response_class=HTMLResponse)
def move(request: Request, index: int, name: str = Form(""), step: str = Form("")):
if step not in ("up", "down"):
return plugin_list(request, 400, "Choose up or down.")
try:
hand_edits = editor.move(index, name, -1 if step == "up" else 1)
except EditError as error:
return plugin_list(request, 409, str(error))
return saved("saved", hand_edits)

@app.post("/plugins/{index}/enabled", response_class=HTMLResponse)
def switch(request: Request, index: int, name: str = Form(""), on: str = Form("")):
try:
hand_edits = editor.set_enabled(index, name, on == "1")
except EditError as error:
return plugin_list(request, 409, str(error))
return saved("saved", hand_edits)

@app.get("/plugins/{index}/remove", response_class=HTMLResponse)
def remove_page(request: Request, index: int, name: str = ""):
try:
row = editor.row(index, name)
except EditError as error:
return plugin_list(request, 409, str(error))
return page(request, "remove.html", row=row)

@app.post("/plugins/{index}/remove", response_class=HTMLResponse)
def remove(request: Request, index: int, name: str = Form("")):
try:
hand_edits = editor.remove(index, name)
except EditError as error:
return plugin_list(request, 409, str(error))
return saved("removed", hand_edits)

@app.get("/library", response_class=HTMLResponse)
def library_page(request: Request):
return page(request, "library.html", items=library())

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.")
values.setdefault("name", editor.suggested_name(item))
return page(request, "add.html", status, item=item, **values)

@app.get("/library/{plugin_type}", response_class=HTMLResponse)
def add_form(request: Request, plugin_type: str):
return add_page(request, plugin_type)

@app.post("/library/{plugin_type}", response_class=HTMLResponse)
def add(request: Request, plugin_type: str, name: str = Form("")):
item = library_item(plugin_type)
if item is None:
return add_page(request, plugin_type)
try:
hand_edits = editor.add(item, name)
except EditError as error:
return add_page(request, plugin_type, 400, name=name, problem=str(error))
return saved("added", hand_edits, name.strip())

@app.post("/logout")
def logout():
response = RedirectResponse("/login", status_code=303)
Expand Down
Loading
Loading