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
12 changes: 6 additions & 6 deletions src/paperpi/plugins/splash_screen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@ What it shows:

- the name, in large letters;
- the version of the PaperPi that is running, for example `2.0.0`;
- the web interface's address in two forms, each on its own line (or two, see below): by IP address (`http://192.0.2.10:8080`) and by host name (`http://paperpi.local:8080`). Some phones can't open `.local` names; the IP address always works, but can change when the router gives the Pi a new one;
- a QR code with the IP address, to open the web interface on a phone;
- the web interface's address by IP address, e.g. `http://192.0.2.10:8080`. Not by name (`paperpi.local`): the web interface only accepts IP addresses (see the main README). The address can change when the router gives the Pi a new one;
- a QR code with the same address, to open the web interface on a phone;
- PaperPi's GitHub address.

Without a network it says "No network" instead, with no QR code.

An address is never cut off: when it doesn't fit on one line, it is broken after a "/" (or, in a long host name, after a "." or "-"), drawn smaller if needed, and as a last step broken between letters (v1 split the GitHub address over two lines for the same reason). Only a very long host name without any "." or "-" (over about 40 letters) can still be too long for a small screen.
An address is never cut off: when it doesn't fit on one line, it is broken after a "/" (or after a "." or ":"), drawn smaller if needed, and as a last step broken between letters (v1 split the GitHub address over two lines for the same reason).

The web interface comes in milestone M5; until then the port is fixed at 8080, the port it will use. In Docker (M6), PaperPi may see the container's address instead of the Pi's; M6 deals with that.
The port is the web interface's port, `[web] port` (default 8080); like the web interface itself, a change applies at the next start of PaperPi. In Docker (M6), PaperPi may see the container's address instead of the Pi's; M6 deals with that.

## Layouts

Expand All @@ -26,7 +26,7 @@ The web interface comes in milestone M5; until then the port is fixed at 8080, t

## Settings

None of its own, only the settings every plugin has (see the main [README](../../../../README.md)). It suggests a refresh every hour, which matters while no plugin is switched on and in the rotation: it then picks up a new IP address.
One of its own, `port` (default 8080): the port in the addresses, only for a splash you add to the rotation yourself; the splash at start always uses `[web] port`. Plus the settings every plugin has (see the main [README](../../../../README.md)). It suggests a refresh every hour, which matters while no plugin is switched on and in the rotation: it then picks up a new IP address.

To put it in the rotation (not needed for the splash at start):

Expand All @@ -42,7 +42,7 @@ The name is in [Anton](https://fonts.google.com/specimen/Anton) by The Anton Pro

## Sample images

The sample images show version 2.0.0, the made-up address 192.0.2.10 and the host name `paperpi`. All sample images are in [`tests/images/`](../../../../tests/images/), named `splash_screen-splash-<screen>.png`, where `<screen>` is `9in7` (9.7", 1200x825, 16 grays), `7in5` (7.5", 800x480, black and white) or `5in65` (5.65", 600x448, 7 colours).
The sample images show version 2.0.0, and the made-up address 192.0.2.10. All sample images are in [`tests/images/`](../../../../tests/images/), named `splash_screen-splash-<screen>.png`, where `<screen>` is `9in7` (9.7", 1200x825, 16 grays), `7in5` (7.5", 800x480, black and white) or `5in65` (5.65", 600x448, 7 colours).

| 9.7" | 7.5" black and white | 5.65" 7 colours |
|---|---|---|
Expand Down
61 changes: 23 additions & 38 deletions src/paperpi/plugins/splash_screen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
It is a plugin like any other so start-up needs no special drawing code; a ``[[plugin]]``
block can also put it in the rotation, but that is not what it is meant for.

It shows the address of PaperPi's web interface, as an IP address and as a host name, and
a QR code with the IP address, so a phone can open the web interface at once.
It shows the address of PaperPi's web interface by IP address, and a QR code with that
address, so a phone can open the web interface at once. (Not by host name: the web
interface only accepts IP addresses, see docs/decisions/web-interface.md.)
"""

import socket
Expand All @@ -17,16 +18,14 @@
import segno
from epdlib import text
from PIL import Image
from pydantic import Field

from ... import __version__
from ...plugin import Context, Plugin, PluginSettings, ready
from .layouts import ADDRESS_BLOCKS, LAYOUTS, PADDING

NAME = "PaperPi"
URL = "https://github.com/txoof/PaperPi"
#: The port of the web interface (docs/decisions/web-interface.md). M5 replaces this with
#: the port setting of the web interface.
WEB_PORT = 8080
NO_NETWORK = "No network"


Expand All @@ -39,12 +38,16 @@ class About:
url: str
ip: str | None
"""This Pi's address on the network; ``None`` without a network."""
hostname: str | None
"""This Pi's name on the network (without ``.local``); ``None`` when unknown."""


class Settings(PluginSettings):
pass
port: int = Field(
8080,
ge=1,
le=65535,
strict=True,
description="The web interface's port. The splash at start always uses [web] port",
)


def ip_address() -> str | None:
Expand All @@ -62,32 +65,18 @@ def ip_address() -> str | None:
return None if address.startswith(("0.", "127.")) else address


def hostname() -> str | None:
"""This Pi's name, e.g. ``paperpi``; ``None`` when the system doesn't say."""
try:
return socket.gethostname() or None
except OSError:
return None


def fetch(context: Context):
return ready(About(NAME, __version__, URL, ip_address(), hostname()))
return ready(About(NAME, __version__, URL, ip_address()))


def web_addresses(about: About) -> tuple[str, str] | None:
"""The web interface's address by IP and by host name; ``None`` without a network."""
if about.ip is None:
return None
host = about.hostname or ""
if host and "." not in host:
host += ".local" # the name the Pi announces on the home network
by_name = f"http://{host}:{WEB_PORT}" if host else ""
return f"http://{about.ip}:{WEB_PORT}", by_name
def web_address(about: About, port: int) -> str | None:
"""The web interface's address; ``None`` without a network."""
return None if about.ip is None else f"http://{about.ip}:{port}"


def line_breaks(address: str) -> list[str]:
"""Ways to write ``address``: on one line, then broken after a "/", then (for a long
host name) also after a ".", "-" or ":".
"""Ways to write ``address``: on one line, then broken after a "/", then also after a
".", "-" or ":".

epdlib only breaks lines at spaces (or, in a word that is too long, between letters),
so these breaks are put in here. Within each kind the most even split comes first, e.g.
Expand Down Expand Up @@ -159,15 +148,11 @@ def draw(about: About, context: Context) -> dict:
prepared = layout.prepare(context.width, context.height, context.mode)
short_side = min(context.width, context.height)
values = {"name": about.name, "version": about.version}
addresses = web_addresses(about)
texts = {"github": about.url}
if addresses is None:
texts["ip"] = NO_NETWORK
else:
texts["ip"], texts["host"] = addresses
box = prepared.boxes["qr"]
if box.width > 0 and box.height > 0:
values["qr"] = qr_code(addresses[0], box.width, box.height)
address = web_address(about, context.settings.port)
texts = {"github": about.url, "ip": address or NO_NETWORK}
box = prepared.boxes["qr"]
if address and box.width > 0 and box.height > 0:
values["qr"] = qr_code(address, box.width, box.height)
edge = round(PADDING * short_side)
for name in ADDRESS_BLOCKS:
value = texts.get(name, "")
Expand All @@ -193,7 +178,7 @@ def draw(about: About, context: Context) -> dict:
draw=draw,
# A fixed version and made-up addresses, so the sample images never change by
# themselves and never show a real Pi's address.
sample=About(NAME, "2.0.0", URL, "192.0.2.10", "paperpi"),
sample=About(NAME, "2.0.0", URL, "192.0.2.10"),
# Nothing changes while PaperPi runs, except perhaps the IP address.
refresh=3600,
)
19 changes: 6 additions & 13 deletions src/paperpi/plugins/splash_screen/layouts.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
"""Layouts for splash_screen. The first one is the default.

As in v1, the name is in Anton and the rest in Dosis SemiBold. Below the name and version:
the web interface's address (by IP address and by host name) next to a QR code with the
IP address, and PaperPi's GitHub address at the bottom.
the web interface's address (by IP address) next to a QR code with the same address, and
PaperPi's GitHub address at the bottom.
"""

from ... import fonts

PADDING = 0.01

#: The text blocks that hold an address; ``draw`` breaks these after a "/" when needed.
ADDRESS_BLOCKS = ("ip", "host", "github")
ADDRESS_BLOCKS = ("ip", "github")

# One sample for both web addresses, so they get the same font size.
WEB_SAMPLE = "http://paperpi-kitchen.local:8080"
# The longest web address: the longest IP address and port.
WEB_SAMPLE = "http://255.255.255.255:65535"


def _text(name: str, size: float, font: str, sample: str, max_lines: int = 1) -> dict:
Expand All @@ -37,14 +37,7 @@ def _text(name: str, size: float, font: str, sample: str, max_lines: int = 1) ->
_text("version", 1, fonts.DOSIS_SEMIBOLD, "88.88.88.dev88"),
{
"row": [
{
"column": [
_text("ip", 1, fonts.DOSIS_SEMIBOLD, WEB_SAMPLE, 2),
_text("host", 1, fonts.DOSIS_SEMIBOLD, WEB_SAMPLE, 2),
],
"size": 3,
"gap": 0,
},
_text("ip", 3, fonts.DOSIS_SEMIBOLD, WEB_SAMPLE, 2),
# Drawn at its exact size in draw(), so it is never scaled again.
{"name": "qr", "type": "image", "size": 1, "fit": "none"},
],
Expand Down
11 changes: 7 additions & 4 deletions src/paperpi/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,9 @@ def __init__(
self._default = _Slot(_default_config(config))
self._fallback = _Slot(_fallback_config()) if config.display.fallback_clock else None
"""The fallback clock, shown when no plugin has anything to show."""
self._splash = _Slot(_splash_config())
self._web_port = config.web.port
"""The web interface's port: it only changes at the next start (WEB_NEXT_START)."""
self._splash = _Slot(_splash_config(self._web_port))
"""The splash screen: at start, and while no plugin is switched on."""
self._starting = config.display.splash_time > 0
"""The splash screen is still to be shown (or being shown) at start."""
Expand Down Expand Up @@ -875,13 +877,14 @@ def _fallback_config() -> PluginConfig:
return PluginConfig(entry, plugin.settings(), plugin)


def _splash_config() -> PluginConfig:
"""The splash screen shown at start, and while no plugin is switched on."""
def _splash_config(web_port: int) -> PluginConfig:
"""The splash screen shown at start, and while no plugin is switched on. It shows the
web interface's address, with the port the web interface started with."""
plugin = plugins.load("splash_screen")
# Its time on screen comes from [display] splash_time, not from display_time. A short
# time limit: while its first picture is drawn, the screen waits for it.
entry = PluginEntry(name="built-in splash", type="splash_screen", time_limit=30)
return PluginConfig(entry, plugin.settings(), plugin)
return PluginConfig(entry, plugin.settings(port=web_port), plugin)


def _same_plugin(a: PluginConfig, b: PluginConfig) -> bool:
Expand Down
Binary file modified tests/images/splash_screen-splash-5in65.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/splash_screen-splash-7in5.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/images/splash_screen-splash-9in7.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 18 additions & 0 deletions tests/test_scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,24 @@ def test_reload_does_not_show_the_splash_again(tmp_path):
assert sim.writes == [(1, "BUILT-IN-SPLASH"), (61, "A")]


def test_splash_keeps_the_web_port_until_the_next_start(tmp_path):
def ports():
return [c.settings.port for c in sim.updates.contexts if c.storage.name == SPLASH]

sim = Sim(tmp_path, display="splash_time = 60\n[web]\nport = 9000") # no plugins
sim.run(until=10)
assert ports() == [9000]
sim.next_config = make_config(display="splash_time = 60\n[web]\nport = 9100")
sim.at(20, sim.scheduler.reload)
sim.run(until=40)
# The web interface keeps its port until the next start, so the splash does too.
sim.plan(**{SPLASH: lambda t: f"splash {int(t)}"})
sim.next_config = make_config(display="splash_time = 60\nrotation = 90\n[web]\nport = 9100")
sim.at(50, sim.scheduler.reload)
sim.run(until=70)
assert ports() == [9000, 9000] # drawn again (turned), still with the old port


def test_reload_changing_splash_time_during_the_splash_applies(tmp_path):
sim = Sim(tmp_path, rotation("a", refresh=5000), display="splash_time = 60")
sim.next_config = make_config(rotation("a", refresh=5000), display="splash_time = 300")
Expand Down
74 changes: 28 additions & 46 deletions tests/test_splash_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import pytest
import segno
from epdlib import ScreenMode, text
from pydantic import ValidationError

from paperpi import __version__
from paperpi.plugin import Context, State, draw_update
Expand All @@ -22,7 +23,7 @@
fit_address,
line_breaks,
qr_code,
web_addresses,
web_address,
)
from paperpi.plugins.splash_screen.layouts import ADDRESS_BLOCKS, PADDING

Expand All @@ -37,10 +38,9 @@ def context(width=800, height=480, mode=None):

def test_fetch_shows_this_paperpi(monkeypatch):
monkeypatch.setattr(splash_screen, "ip_address", lambda: "192.0.2.20")
monkeypatch.setattr(splash_screen, "hostname", lambda: "kitchen")
fetched = fetch(context())
assert fetched.state is State.READY
assert fetched.data == About(NAME, __version__, URL, "192.0.2.20", "kitchen")
assert fetched.data == About(NAME, __version__, URL, "192.0.2.20")


class FakeSocket:
Expand Down Expand Up @@ -78,46 +78,39 @@ def test_ip_address(monkeypatch, fake, expected):
assert splash_screen.ip_address() == expected


@pytest.mark.parametrize(("name", "expected"), [("kitchen", "kitchen"), ("", None)])
def test_hostname(monkeypatch, name, expected):
monkeypatch.setattr(splash_screen.socket, "gethostname", lambda: name)
assert splash_screen.hostname() == expected
def test_sample_is_fixed_and_made_up():
# A fixed version keeps the sample images the same from one release to the next, and
# 192.0.2.x is set aside for examples, so no real Pi's address is ever shown.
assert SAMPLE == About(NAME, "2.0.0", URL, "192.0.2.10")


def test_hostname_error(monkeypatch):
def broken():
raise OSError("no name")
def test_web_address():
assert web_address(SAMPLE, 8080) == "http://192.0.2.10:8080"

monkeypatch.setattr(splash_screen.socket, "gethostname", broken)
assert splash_screen.hostname() is None

def test_no_web_address_without_a_network():
assert web_address(replace(SAMPLE, ip=None), 8080) is None

def test_sample_is_fixed_and_made_up():
# A fixed version keeps the sample images the same from one release to the next, and
# 192.0.2.x is set aside for examples, so no real Pi's address is ever shown.
assert SAMPLE == About(NAME, "2.0.0", URL, "192.0.2.10", "paperpi")

def test_the_default_port_is_the_web_interface_default():
from paperpi.config import WebSettings

@pytest.mark.parametrize(
("hostname", "by_name"),
[
("paperpi", "http://paperpi.local:8080"),
("pi.example.org", "http://pi.example.org:8080"), # a name with a dot stays as it is
(None, ""),
],
)
def test_web_addresses(hostname, by_name):
about = replace(SAMPLE, hostname=hostname)
assert web_addresses(about) == ("http://192.0.2.10:8080", by_name)
assert Settings().port == WebSettings().port == 8080


def test_no_web_address_without_a_network():
assert web_addresses(replace(SAMPLE, ip=None)) is None
@pytest.mark.parametrize("port", [0, 65536, "9000"])
def test_port_must_be_a_real_port_number(port):
with pytest.raises(ValidationError):
Settings(port=port)


def test_the_port_is_the_web_interface_port():
# docs/decisions/web-interface.md: plain HTTP on port 8080.
assert splash_screen.WEB_PORT == 8080
def test_addresses_and_qr_code_use_the_port():
ctx = replace(context(), settings=Settings(port=9000))
values = draw(SAMPLE, ctx)
assert values["ip"].replace("\n", "") == "http://192.0.2.10:9000"
box = PLUGIN.layout("splash", Settings()).prepare(800, 480, ScreenMode.bw()).boxes["qr"]
expected = qr_code("http://192.0.2.10:9000", box.width, box.height)
assert values["qr"].tobytes() == expected.tobytes()


def test_line_breaks_after_a_slash_come_first_most_even_first():
Expand Down Expand Up @@ -177,14 +170,6 @@ def test_fit_address_falls_back_to_one_line():
assert fit("http://192.0.2.10:8080", 40, 30, 40) == "http://192.0.2.10:8080"


def test_long_host_name_is_broken_after_a_dash_not_inside_a_word():
values = draw(replace(SAMPLE, hostname="paperpi-living-room-kitchen-shelf"), context(480, 800))
host = values["host"]
assert host.replace("\n", "") == "http://paperpi-living-room-kitchen-shelf.local:8080"
assert "\n" in host
assert all(line[-1] in "/.-:" for line in host.split("\n")[:-1])


def test_qr_code_holds_the_address_with_whole_pixels_per_square():
image = qr_code("http://192.0.2.10:8080", 200, 150)
side = segno.make("http://192.0.2.10:8080", error="m").symbol_size(border=2)[0]
Expand All @@ -204,18 +189,16 @@ def test_draw_fills_every_block_of_the_layout():
assert values["name"] == "PaperPi"
assert values["version"] == "2.0.0"
assert values["ip"].replace("\n", "") == "http://192.0.2.10:8080"
assert values["host"].replace("\n", "") == "http://paperpi.local:8080"
assert values["github"].replace("\n", "") == URL
box = PLUGIN.layout("splash", Settings()).prepare(800, 480, ScreenMode.bw()).boxes["qr"]
# The QR code holds the IP address, never the host name (some phones can't open it).
# The QR code holds the same address as the text.
expected = qr_code("http://192.0.2.10:8080", box.width, box.height)
assert values["qr"].tobytes() == expected.tobytes()


def test_draw_without_a_network():
values = draw(replace(SAMPLE, ip=None), context())
assert values["ip"] == NO_NETWORK
assert values["host"] == ""
assert "qr" not in values


Expand Down Expand Up @@ -248,10 +231,9 @@ def _complete(values, width, height):
"about",
[
SAMPLE,
replace(SAMPLE, ip="255.255.255.255", hostname="paperpi-living-room"),
replace(SAMPLE, hostname="paperpi-living-room-kitchen"),
replace(SAMPLE, ip="255.255.255.255"),
],
ids=["sample", "long-names", "longer-name"],
ids=["sample", "longest-ip"],
)
def test_addresses_always_appear_whole(about, width, height):
values = draw(about, context(width, height))
Expand Down
Loading