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
+23
View File
@@ -560,6 +560,24 @@ def run_test(revision: str) -> None:
log.info("Testbild gezeichnet (EPD_REVISION=%s)", revision)
def run_final(revision: str, kind: str) -> None:
"""Zeichnet den Abschlussbildschirm ("poweroff"/"reboot") und legt das Panel schlafen.
Das ist das Bild, das nach dem Herunterfahren DAUERHAFT stehen bleibt - E-Paper ist
bistabil, es haelt sein Bild auch ohne Strom. Bewusst ein VOLLAUFBAU: ein Teilaufbau
liesse Reste des vorigen Bildes stehen, und dieses eine Bild ueberdauert alle anderen.
Aufgerufen wird das ausschliesslich ueber "ninlight-station display final <kind>" aus
einer systemd-Unit heraus, und zwar ERST, nachdem der Anzeigedienst gestoppt ist - er
haelt sonst die GPIO-/SPI-Leitungen ("lgpio.error: 'GPIO busy'"). Diese Uebergabe
macht das CLI (display_final()), nicht diese Funktion."""
panel = Panel(revision)
panel.clear()
panel.draw_full(render.compose({"mode": "final", "final": kind}))
panel.sleep()
log.info("Abschlussbild gezeichnet (%s, EPD_REVISION=%s)", kind, revision)
def run_clear(revision: str) -> None:
panel = Panel(revision)
panel.clear()
@@ -634,6 +652,9 @@ def main() -> None:
group = parser.add_mutually_exclusive_group()
group.add_argument("--test", action="store_true", help="Testbild zeichnen und beenden")
group.add_argument("--clear", action="store_true", help="Panel leeren und beenden")
group.add_argument("--final", choices=sorted(render.FINAL_LABELS),
help="Abschlussbild zeichnen und beenden (beim Herunterfahren "
"bzw. Neustart; die Texte unterscheiden sich)")
args = parser.parse_args()
revision = os.environ.get("EPD_REVISION", "V3")
@@ -643,6 +664,8 @@ def main() -> None:
run_test(revision)
elif args.clear:
run_clear(revision)
elif args.final:
run_final(revision, args.final)
else:
run_daemon(revision)
except (DisplayUnavailable, ImportError) as e:
+31
View File
@@ -200,6 +200,23 @@ IMAGESET_LABELS = {
}
IMAGESET_MISSING_SUFFIX = " - SATZ FEHLT"
# Der Abschlussbildschirm: was auf dem Panel stehen bleibt, wenn der Pi heruntergefaehrt
# oder neu gestartet wird. E-Paper ist bistabil - das zuletzt gezeichnete Bild bleibt fuer
# immer stehen, auch stromlos. Ohne diesen Schritt behauptet das Panel nach dem
# Ausschalten weiter den Zustand von vorher (bei der Abnahme stand dort die RUECKFRAGE,
# und der Pi sah aus, als warte er noch auf eine Eingabe).
#
# Die beiden Texte unterscheiden sich, und das ist der Punkt: beim Ausschalten DARF man
# den Stecker ziehen, beim Neustart NICHT - ein Zero W braucht gut anderthalb Minuten, bis
# der Anzeigedienst wieder zeichnet, und an genau diesem Pi hat ein Stromzug in dieser
# Wartezeit schon eine SD-Karte gekostet.
FINAL_LABELS = {
"poweroff": ("AUSGESCHALTET", "Strom kann getrennt werden", []),
"reboot": ("NEUSTART", "Strom NICHT trennen!",
["Der Pi kommt in rund 2 min wieder.",
"Solange bleibt dieses Bild stehen."]),
}
# Woraus die Station die Signiervariante abgeleitet hat (products.classify_variant):
# "-dev" kann in der Produkt-ID stehen, in der Version, oder in beiden. Die Quelle steht
# mit auf dem Panel, weil genau hier am 2026-09-15 ein falsch signiertes Image
@@ -250,6 +267,20 @@ def blocks(state: dict) -> list[tuple[str, str]]:
mode = state.get("mode")
out: list[tuple[str, str]] = []
if mode == "final":
# Abschlussbildschirm - KEINE Fusszeile und keine weiteren Zeilen: die Station ist
# im Begriff zu verschwinden, und die einzige Auskunft, auf die es jetzt ankommt,
# ist "darf ich den Stecker ziehen?".
label = FINAL_LABELS.get(state.get("final"))
if label is None:
# Unbekannte Spielart - lieber ehrlich nichts behaupten als "ausgeschaltet"
# zu sagen, wenn gleich wieder gebootet wird.
return [("Faehrt herunter", ROLE_BIG)]
title, hero, details = label
return ([(title, ROLE_HEADER), (hero, ROLE_HERO)]
+ [(d, ROLE_DETAIL) for d in details])
# Die Fusszeile haengt an JEDEM Statusbild - auch an "Station aus" und an "Status
# unbekannt", die beide sofort zurueckkehren. Sie wird deshalb hier gebaut und unten
# bei jedem Rueckgabepunkt angehaengt.