diff --git a/src/paperpi/plugins/splash_screen/README.md b/src/paperpi/plugins/splash_screen/README.md index 4dc7621..bf51cf8 100644 --- a/src/paperpi/plugins/splash_screen/README.md +++ b/src/paperpi/plugins/splash_screen/README.md @@ -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 @@ -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): @@ -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-.png`, where `` 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-.png`, where `` 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 | |---|---|---| diff --git a/src/paperpi/plugins/splash_screen/__init__.py b/src/paperpi/plugins/splash_screen/__init__.py index 6da9639..1584a50 100644 --- a/src/paperpi/plugins/splash_screen/__init__.py +++ b/src/paperpi/plugins/splash_screen/__init__.py @@ -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 @@ -17,6 +18,7 @@ import segno from epdlib import text from PIL import Image +from pydantic import Field from ... import __version__ from ...plugin import Context, Plugin, PluginSettings, ready @@ -24,9 +26,6 @@ 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" @@ -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: @@ -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. @@ -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, "") @@ -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, ) diff --git a/src/paperpi/plugins/splash_screen/layouts.py b/src/paperpi/plugins/splash_screen/layouts.py index 01ec129..e056a19 100644 --- a/src/paperpi/plugins/splash_screen/layouts.py +++ b/src/paperpi/plugins/splash_screen/layouts.py @@ -1,8 +1,8 @@ """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 @@ -10,10 +10,10 @@ 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: @@ -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"}, ], diff --git a/src/paperpi/scheduler.py b/src/paperpi/scheduler.py index 7a16136..eadeb69 100644 --- a/src/paperpi/scheduler.py +++ b/src/paperpi/scheduler.py @@ -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.""" @@ -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: diff --git a/tests/images/splash_screen-splash-5in65.png b/tests/images/splash_screen-splash-5in65.png index d006562..662f4a1 100644 Binary files a/tests/images/splash_screen-splash-5in65.png and b/tests/images/splash_screen-splash-5in65.png differ diff --git a/tests/images/splash_screen-splash-7in5.png b/tests/images/splash_screen-splash-7in5.png index 1168faa..8f11c30 100644 Binary files a/tests/images/splash_screen-splash-7in5.png and b/tests/images/splash_screen-splash-7in5.png differ diff --git a/tests/images/splash_screen-splash-9in7.png b/tests/images/splash_screen-splash-9in7.png index 3f891c4..910305f 100644 Binary files a/tests/images/splash_screen-splash-9in7.png and b/tests/images/splash_screen-splash-9in7.png differ diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index f3173a7..eb46852 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -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") diff --git a/tests/test_splash_screen.py b/tests/test_splash_screen.py index b0d6dda..6f314b5 100644 --- a/tests/test_splash_screen.py +++ b/tests/test_splash_screen.py @@ -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 @@ -22,7 +23,7 @@ fit_address, line_breaks, qr_code, - web_addresses, + web_address, ) from paperpi.plugins.splash_screen.layouts import ADDRESS_BLOCKS, PADDING @@ -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: @@ -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(): @@ -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] @@ -204,10 +189,9 @@ 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() @@ -215,7 +199,6 @@ def test_draw_fills_every_block_of_the_layout(): 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 @@ -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))