feat(station): Abschlussbild beim Herunterfahren und beim Neustart

E-Paper behaelt sein Bild dauerhaft, auch stromlos. Faehrt der Pi
herunter, wird der Anzeigedienst mit abgeraeumt und das zuletzt
gezeichnete Bild bleibt fuer immer stehen - bei der Abnahme stand dort
die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als
warte er noch auf eine Eingabe.

Zwei neue Units, ninlight-poweroff.service (WantedBy=poweroff.target)
und ninlight-reboot.service (WantedBy=reboot.target). Sie haengen am
System, nicht am Tasterdienst: so greifen sie bei jedem Herunterfahren -
Menue, "poweroff" per SSH, "reboot" - und nicht nur auf einem von drei
Wegen. Zwei Units statt einer Fallunterscheidung, weil systemd einem
ExecStart nicht sagt, wohin die Reise geht.

Die Texte unterscheiden sich: "AUSGESCHALTET / Strom kann getrennt
werden" gegen "NEUSTART / Strom NICHT trennen!" samt Hinweis auf die
rund zwei Minuten, die der Zero W bis zum Anzeigedienst braucht. An
diesem Pi hat ein Stromzug in genau dieser Wartezeit schon eine
SD-Karte gekostet.

Reihenfolge: die Units ordnen sich per After=ninlight-display.service
hinter dessen Stop ein, und "ninlight-station display final <kind>"
wartet zusaetzlich, bis systemctl is-active nicht mehr anschlaegt -
dasselbe Muster wie display_handover(), nur ohne Wiederanfahren. Gibt
der Dienst die Leitungen nicht frei, wird nicht gezeichnet (GPIO busy),
sondern gewarnt: ein Anzeigefehler haelt das Herunterfahren nie auf.

Nebenbei behoben: display_run() reichte nur "$1" an display.py weiter
und haette "--final poweroff" halbiert.
This commit is contained in:
Michael Sauer
2026-09-15 03:02:21 +02:00
parent 300edafc3e
commit 4e1a965cbb
12 changed files with 572 additions and 4 deletions
+123
View File
@@ -1865,3 +1865,126 @@ def test_the_display_and_the_server_agree_on_the_set_names():
spec.loader.exec_module(server)
assert display.IMAGESET_DIRS == server.IMAGE_SET_DIRS
assert display.IMAGESET_DEFAULT == server.DEFAULT_IMAGE_SET
# --- Abschlussbildschirm beim Herunterfahren und beim Neustart -----------------------
#
# E-Paper ist bistabil: das zuletzt gezeichnete Bild bleibt fuer immer stehen, auch
# stromlos. Ohne dieses Bild behauptet das Panel nach dem Ausschalten weiter den Zustand
# von vorher - bei der Abnahme stand dort die Rueckfrage des Tastermenues, und der Pi sah
# aus, als warte er noch auf eine Eingabe.
def _final_text(kind):
return " ".join(render.lines({"mode": "final", "final": kind}))
def test_the_shutdown_and_the_reboot_screen_differ():
"""Der wichtigste Unterschied im ganzen Bild: beim Ausschalten DARF man den Stecker
ziehen, beim Neustart NICHT (an diesem Pi hat ein Stromzug schon eine SD-Karte
gekostet)."""
off, reboot = _final_text("poweroff"), _final_text("reboot")
assert off != reboot
assert "AUSGESCHALTET" in off and "NEUSTART" in reboot
# Ausschalten: erlaubt das Trennen ausdruecklich.
assert "kann getrennt werden" in off
# Neustart: verbietet es ebenso ausdruecklich - und sagt nirgends das Gegenteil.
assert "NICHT trennen" in reboot
assert "kann getrennt werden" not in reboot
def test_the_reboot_screen_says_how_long_it_stays():
"""Ein Zero W braucht gut anderthalb Minuten bis zum Anzeigedienst - ohne diesen Satz
sieht das stehende Bild nach "haengt" aus, und genau dann greift jemand zum Stecker."""
assert "min" in _final_text("reboot")
def test_the_final_screen_carries_nothing_else():
"""Kein Bildsatz, keine Fusszeile, kein Zaehler: die Station ist im Begriff zu
verschwinden, und die einzige Auskunft, auf die es jetzt ankommt, ist "Stecker?"."""
state = {"mode": "final", "final": "poweroff", "imageset": "mit-wlan",
"imageset_ok": True, "served": 7}
blocks = render.blocks(state)
assert not any(role == render.ROLE_FOOTER for _, role in blocks)
assert not any("Bildsatz" in text or "Ausgeliefert" in text for text, _ in blocks)
def test_an_unknown_final_kind_claims_neither():
"""Lieber gar nichts behaupten als "ausgeschaltet" zu sagen, wenn gleich wieder
gebootet wird."""
text = " ".join(render.lines({"mode": "final", "final": "was-auch-immer"}))
assert "AUSGESCHALTET" not in text
assert "getrennt" not in text
def test_both_final_screens_are_actually_drawable():
for kind in ("poweroff", "reboot"):
image = render.compose({"mode": "final", "final": kind})
assert image.size == (render.PANEL_WIDTH_PX, render.PANEL_HEIGHT_PX)
assert _ink_box(image) is not None, kind
def test_run_final_draws_fully_and_sleeps(monkeypatch):
"""Ueber die ECHTE display.run_final(): Vollaufbau (kein Teilaufbau - dieses eine Bild
ueberdauert alle anderen und darf keine Reste des vorigen tragen) und danach schlafen
legen, damit das Panel nicht unter Bias in den Ruhezustand geht."""
calls = []
class _Panel:
def __init__(self, revision):
calls.append(("init", revision))
def clear(self):
calls.append(("clear", None))
def draw_full(self, image):
calls.append(("full", image))
def draw_partial(self, image):
calls.append(("partial", image))
return False
def sleep(self):
calls.append(("sleep", None))
monkeypatch.setattr(display, "Panel", _Panel)
display.run_final("V3", "reboot")
kinds = [k for k, _ in calls]
assert kinds == ["init", "clear", "full", "sleep"], kinds
# ... und gezeichnet wurde wirklich das Neustart-Bild, nicht irgendeines.
image = dict((k, v) for k, v in calls)["full"]
expected = render.compose({"mode": "final", "final": "reboot"})
assert list(image.getdata()) == list(expected.getdata())
assert list(image.getdata()) != list(
render.compose({"mode": "final", "final": "poweroff"}).getdata())
def test_display_main_passes_the_final_kind_through(monkeypatch):
"""Die Verdrahtung in display.main() selbst: "--final reboot" muss beim Neustartbild
landen. Ohne diese Zusage liesse sich die Spielart in main() durch eine feste
ersetzen, ohne dass ein Test rot wuerde - und auf dem Panel stuende beim Neustart
"Strom kann getrennt werden"."""
drawn = []
class _Panel:
def __init__(self, revision):
pass
def clear(self):
pass
def draw_full(self, image):
drawn.append(image)
def sleep(self):
pass
monkeypatch.setattr(display, "Panel", _Panel)
monkeypatch.setenv("EPD_REVISION", "V3")
for kind in ("reboot", "poweroff"):
drawn.clear()
monkeypatch.setattr(sys, "argv", ["display.py", "--final", kind])
display.main()
assert len(drawn) == 1
expected = render.compose({"mode": "final", "final": kind})
assert list(drawn[0].getdata()) == list(expected.getdata()), kind