fix(display): Phase im Kopfbalken, Rand unten, Wants-Fehler behoben

Drei Befunde der Hardware-Abnahme: die Migrationsphase stand als eigene
kleine Zeile unter dem Lampennamen und ging dort unter - sie steht jetzt
rechtsbuendig neben dem Modus in der invertierten Kopfzeile (mit
Kuerzung/kleinerer Schrift/Weglassen als gestaffelte Antwort auf eine zu
lange Phase, nie mit Ueberlappung). Die letzte Beiwerkzeile klebte am
unteren Panelrand - durch das Verschieben der Phase in den Kopf bleiben
nur noch fuenf statt sechs Beiwerkzeilen, zusaetzlich durchgesetzt per
BOTTOM_MARGIN_PX in der Abbruchbedingung von compose().

Ausserdem ein am Geraet nachgewiesener Fund: ninlight-display.service
trug Wants=ninlight-station.service NEBEN dem gewollten After= - Wants=
aktiviert die genannte Unit aber, statt nur zu ordnen, und zog den
absichtlich deaktivierten Stationsdienst bei jedem Boot ohne
Zugangspunkt in einen "failed"-Zustand. Wants= entfernt, After= bleibt.
This commit is contained in:
Michael Sauer
2026-09-14 03:11:32 +02:00
parent bb39105ac4
commit 03d6328dd5
4 changed files with 319 additions and 44 deletions
+127 -37
View File
@@ -46,32 +46,64 @@ TEXT_MARGIN_PX = 2
# aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt # aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt
# deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders: # deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders:
# #
# header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle Panelbreite. # header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle
# Invertiert kostet auf e-Paper nichts (bistabil, kein Stromverbrauch pro # Panelbreite. Invertiert kostet auf e-Paper nichts (bistabil, kein
# Pixel) und trennt die Kopfzeile auf einen Blick vom Rest. # Stromverbrauch pro Pixel) und trennt die Kopfzeile auf einen Blick
# hero - die eine wichtigste Information des Zustands (Migration: der erkannte # vom Rest.
# Produktname; Rettung: die SSID), fett. # header_phase - die Phase (AUTO/HORCHT/FREIGEGEBEN: ...), NUR im Migrationsmodus und
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung). # nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"): gross und # header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme:
# mittig, damit man quer durch den Raum sieht, dass nichts laeuft. # die Phase ging als eigene kleine Zeile unter dem Lampennamen unter).
# Rettung/aus kennen keine Phase - dort bleibt die Kopfzeile
# unveraendert (nur header, mittig, wie zuvor).
# hero - die eine wichtigste Information des Zustands (Migration: der
# erkannte Produktname; Rettung: die SSID), fett.
# detail - Beiwerk (Ziel/Variante, Lampenversion, Zaehler, letzte Auslieferung).
# big - Zustaende fast ohne Inhalt ("Station aus", "Status unbekannt"):
# gross und mittig, damit man quer durch den Raum sieht, dass nichts
# laeuft.
ROLE_HEADER = "header" ROLE_HEADER = "header"
ROLE_HEADER_PHASE = "header_phase"
ROLE_HERO = "hero" ROLE_HERO = "hero"
ROLE_DETAIL = "detail" ROLE_DETAIL = "detail"
ROLE_BIG = "big" ROLE_BIG = "big"
HEADER_FONT_PX = 20 HEADER_FONT_PX = 20
HEADER_PHASE_FONT_PX = 13
HERO_FONT_PX = 16 HERO_FONT_PX = 16
DETAIL_FONT_PX = 11 DETAIL_FONT_PX = 11
BIG_FONT_PX = 26 BIG_FONT_PX = 26
# Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe: # Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe:
# Kopfbalken + grosse Zeile + sechs Beiwerkzeilen) exakt auf die 122 px passt und die # Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem
# letzte Zeile - "zuletzt ausgeliefert" - nicht mehr hinten abfaellt: # Abstand nach unten uebrig (naechster Kommentar, BOTTOM_MARGIN_PX):
# 22 + 2 + 20 + 6*13 = 122. # 22 + 2 + 20 + 5*13 = 109. Die Phase (frueher eine sechste Beiwerkzeile: "AUTO"/
# "FREIGEGEBEN: ...") steht seit der zweiten Hardware-Abnahme in der Kopfzeile
# (ROLE_HEADER_PHASE) statt als eigene Zeile - das ist der Grund, warum nur noch fuenf
# statt sechs Beiwerkzeilen uebrig bleiben, nicht eine nachtraeglich erfundene Kuerzung.
HEADER_BAR_HEIGHT_PX = 22 HEADER_BAR_HEIGHT_PX = 22
HERO_LINE_HEIGHT_PX = 20 HERO_LINE_HEIGHT_PX = 20
DETAIL_LINE_HEIGHT_PX = 13 DETAIL_LINE_HEIGHT_PX = 13
# Hardware-Abnahme, zweiter Befund: auf dem echten Panel frisst ein Rahmen den unteren
# Rand, der volleste Zustand (s.o., endet bei 109 px) wirkte dort gedraengt. compose()
# bricht Beiwerkzeilen deshalb schon ab, bevor sie in die letzten BOTTOM_MARGIN_PX
# Zeilen hineinragen wuerden (siehe die Abbruchbedingung dort) - mit den obigen 109 px
# bleibt dabei auch im vollsten Zustand jede Zeile sichtbar, es wird nichts abgeschnitten.
BOTTOM_MARGIN_PX = 10
# Mindestabstand zwischen dem Ende des Modus-Texts und dem Anfang der Phase in der
# Kopfzeile (beide weiss auf demselben schwarzen Balken) - ohne diesen Puffer koennten
# beide Texte bei einer langen Phase (z.B. "FREIGEGEBEN: <langer Zielname>")
# ineinanderlaufen.
HEADER_PHASE_GAP_PX = 6
# Bleibt nach Modus-Text und Puffer weniger als das fuer die Phase uebrig, wird sie ganz
# weggelassen statt ein einzelnes "…" hinzuzeichnen - das waere ohnehin keine Auskunft
# mehr wert (dritte Option aus der Abnahme: kuerzen, verkleinern oder weglassen; die
# ersten beiden greifen fuer die im Betrieb vorkommenden Werte immer, das hier ist nur
# das Sicherheitsnetz fuer einen theoretisch sehr langen Zielnamen).
HEADER_PHASE_MIN_WIDTH_PX = 24
# Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert # Suchkette fuer eine echte, skalierbare Schrift, in dieser Reihenfolge probiert
# (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber # (Regular, Bold). ImageFont.load_default(size=...) waere der naheliegende Weg, ist aber
# erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und # erst ab Pillow 10.1 vorhanden - Pi 2 (Raspberry Pi OS Bookworm) hat Pillow 9.4.0, und
@@ -152,9 +184,13 @@ def blocks(state: dict) -> list[tuple[str, str]]:
ssid = state.get("ssid") ssid = state.get("ssid")
if mode == "recovery": if mode == "recovery":
# Im Rettungsmodus ist die SSID das, was der Bediener am Geraet braucht (er muss # Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die
# sich mit genau diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix, # Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin
# das den Platz nur wegnaehme. # wie vor der zweiten Hardware-Abnahme, siehe dort).
#
# Die SSID ist das, was der Bediener am Geraet braucht (er muss sich mit genau
# diesem Netz verbinden) - deshalb gross und ohne "SSID: "-Praefix, das den
# Platz nur wegnaehme.
if ssid: if ssid:
out.append((str(ssid), ROLE_HERO)) out.append((str(ssid), ROLE_HERO))
else: else:
@@ -162,6 +198,19 @@ def blocks(state: dict) -> list[tuple[str, str]]:
# nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als # nur beim Start und nach einer Auslieferung) - display.py reicht sie hier als
# Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine # Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine
# Phase, deshalb dieser Block nur hier drin. # Phase, deshalb dieser Block nur hier drin.
#
# Seit der zweiten Hardware-Abnahme steht die Phase NICHT mehr als eigene
# Beiwerkzeile unter dem Lampennamen (dort ging sie unter), sondern direkt
# NEBEN dem Modus in der Kopfzeile (ROLE_HEADER_PHASE, rechtsbuendig) - deshalb
# wird sie hier direkt nach dem ROLE_HEADER-Block angehaengt, nicht erst nach
# der grossen Zeile.
phase = state.get("phase")
if phase == "armed":
target = state.get("armed_target") or "?"
out.append((f"FREIGEGEBEN: {target}", ROLE_HEADER_PHASE))
elif phase in PHASE_LABELS:
out.append((PHASE_LABELS[phase], ROLE_HEADER_PHASE))
last_seen = state.get("last_seen") last_seen = state.get("last_seen")
if not isinstance(last_seen, dict): if not isinstance(last_seen, dict):
last_seen = {} last_seen = {}
@@ -176,13 +225,6 @@ def blocks(state: dict) -> list[tuple[str, str]]:
elif ssid: elif ssid:
out.append((str(ssid), ROLE_HERO)) out.append((str(ssid), ROLE_HERO))
phase = state.get("phase")
if phase == "armed":
target = state.get("armed_target") or "?"
out.append((f"FREIGEGEBEN: {target}", ROLE_DETAIL))
elif phase in PHASE_LABELS:
out.append((PHASE_LABELS[phase], ROLE_DETAIL))
if product_name: if product_name:
target = last_seen.get("target") target = last_seen.get("target")
variant = last_seen.get("variant") variant = last_seen.get("variant")
@@ -264,16 +306,32 @@ def _load_font(image_font, size_px: int, bold: bool = False):
return image_font.load_default() return image_font.load_default()
def _draw_centered(draw, text: str, font, top: int, height: int, fill: int) -> None: def _draw_text(draw, text: str, font, top: int, height: int, fill: int,
"""Zeichnet `text` waagerecht mittig aufs Panel und senkrecht mittig in das Band align: str = "center") -> int:
[top, top+height). Misst mit textbbox() statt mit der nominellen Schriftgroesse: die """Zeichnet `text` senkrecht mittig in das Band [top, top+height) und waagerecht
tatsaechliche Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich nach `align`: "center" (Panelmitte - bisheriges Verhalten, fuer ROLE_BIG und die
kleiner als die Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf Kopfzeile ohne Phase), "left" (linker Rand + TEXT_MARGIN_PX - der Modus, sobald eine
einem 122 px hohen Panel sichtbar zu tief aus.""" Phase daneben steht) oder "right" (rechter Rand - TEXT_MARGIN_PX - die Phase).
Misst mit textbbox() statt mit der nominellen Schriftgroesse: die tatsaechliche
Tinte einer Zeile ist je nach Zeichen (Ober-/Unterlaengen) deutlich kleiner als die
Zeilenhoehe, und ein nach Zeilenhoehe zentrierter Text sieht auf einem 122 px hohen
Panel sichtbar zu tief aus.
Gibt die gezeichnete Tintenbreite zurueck - compose() braucht sie fuer den Modus in
der Kopfzeile, um daraus auszurechnen, wie viel Platz rechts noch fuer die Phase
uebrig ist."""
bbox = draw.textbbox((0, 0), text, font=font) bbox = draw.textbbox((0, 0), text, font=font)
x = (PANEL_WIDTH_PX - (bbox[2] - bbox[0])) // 2 - bbox[0] ink_width = bbox[2] - bbox[0]
if align == "left":
visual_x = TEXT_MARGIN_PX
elif align == "right":
visual_x = PANEL_WIDTH_PX - TEXT_MARGIN_PX - ink_width
else:
visual_x = (PANEL_WIDTH_PX - ink_width) // 2
y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1] y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1]
draw.text((x, y), text, font=font, fill=fill) draw.text((visual_x - bbox[0], y), text, font=font, fill=fill)
return ink_width
def compose(state: dict): def compose(state: dict):
@@ -285,13 +343,14 @@ def compose(state: dict):
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
fonts = { fonts = {
ROLE_HEADER: _load_font(ImageFont, HEADER_FONT_PX, bold=True), ROLE_HEADER: _load_font(ImageFont, HEADER_FONT_PX, bold=True),
ROLE_HEADER_PHASE: _load_font(ImageFont, HEADER_PHASE_FONT_PX, bold=True),
ROLE_HERO: _load_font(ImageFont, HERO_FONT_PX, bold=True), ROLE_HERO: _load_font(ImageFont, HERO_FONT_PX, bold=True),
ROLE_DETAIL: _load_font(ImageFont, DETAIL_FONT_PX), ROLE_DETAIL: _load_font(ImageFont, DETAIL_FONT_PX),
ROLE_BIG: _load_font(ImageFont, BIG_FONT_PX, bold=True), ROLE_BIG: _load_font(ImageFont, BIG_FONT_PX, bold=True),
} }
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
def fit(raw_text: str, font) -> str: def fit(raw_text: str, font, width_px: float = max_width_px) -> str:
# draw.textlength() wirft ValueError("can't measure length of multiline text"), # draw.textlength() wirft ValueError("can't measure length of multiline text"),
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von # sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3, # main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
@@ -302,28 +361,59 @@ def compose(state: dict):
text = raw_text.replace("\n", " ") text = raw_text.replace("\n", " ")
return _truncate_to_width( return _truncate_to_width(
text, measure=lambda t: draw.textlength(t, font=font), text, measure=lambda t: draw.textlength(t, font=font),
max_width_px=max_width_px) max_width_px=width_px)
block_list = blocks(state)
# Nur wenn eine Phase folgt, ruckt der Modus in der Kopfzeile nach links (siehe
# Rollen-Kommentar oben) - Rettung und "aus" kennen keinen ROLE_HEADER_PHASE-Block
# und behalten die zentrierte Kopfzeile von vor der zweiten Hardware-Abnahme.
has_header_phase = any(role == ROLE_HEADER_PHASE for _, role in block_list)
header_mode_right_edge_px = 0.0
y = TEXT_MARGIN_PX y = TEXT_MARGIN_PX
for raw_text, role in blocks(state): for raw_text, role in block_list:
font = fonts[role] font = fonts[role]
text = fit(raw_text, font)
if role == ROLE_BIG: if role == ROLE_BIG:
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze # Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
# Panel. # Panel.
_draw_centered(draw, text, font, top=0, height=PANEL_HEIGHT_PX, fill=0) text = fit(raw_text, font)
_draw_text(draw, text, font, top=0, height=PANEL_HEIGHT_PX, fill=0,
align="center")
continue continue
if role == ROLE_HEADER: if role == ROLE_HEADER:
text = fit(raw_text, font)
draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0) draw.rectangle((0, 0, PANEL_WIDTH_PX - 1, HEADER_BAR_HEIGHT_PX - 1), fill=0)
_draw_centered(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX, fill=255) align = "left" if has_header_phase else "center"
ink_width = _draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
fill=255, align=align)
if align == "left":
header_mode_right_edge_px = TEXT_MARGIN_PX + ink_width
y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX
continue continue
if role == ROLE_HEADER_PHASE:
# Rechtsbuendig NEBEN dem Modus (der links steht, s.o.) im selben Balken -
# gemessen ab dem tatsaechlichen rechten Rand des Modus-Texts, nicht ab
# einer angenommenen festen Breite: "MIGRATION" und "RETTUNG" sind
# unterschiedlich breit, und nur "MIGRATION" hat je eine Phase daneben.
available_px = (PANEL_WIDTH_PX - TEXT_MARGIN_PX - header_mode_right_edge_px
- HEADER_PHASE_GAP_PX)
if available_px >= HEADER_PHASE_MIN_WIDTH_PX:
text = fit(raw_text, font, width_px=available_px)
_draw_text(draw, text, font, top=0, height=HEADER_BAR_HEIGHT_PX,
fill=255, align="right")
# sonst: Phase weglassen (dritte Option aus der Abnahme) - bei so wenig
# Platz waere selbst ein gekuerztes "…" keine Auskunft mehr wert, und ein
# Ueberlappen mit dem Modus ist nicht erlaubt.
continue
text = fit(raw_text, font)
line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX line_height = HERO_LINE_HEIGHT_PX if role == ROLE_HERO else DETAIL_LINE_HEIGHT_PX
if y + line_height > PANEL_HEIGHT_PX: if y + line_height > PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX:
# Passt nicht mehr aufs Panel - abschneiden statt ueberzeichnen. Die # Passt nicht mehr aufs Panel, ohne in den fuer BOTTOM_MARGIN_PX
# reservierten unteren Rand zu ragen - abschneiden statt ueberzeichnen. Die
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert. # Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
break break
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0) draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
+15 -2
View File
@@ -1,12 +1,25 @@
[Unit] [Unit]
Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation) Description=ninlight E-Paper-Anzeige (Status der Migrations-/Rettungsstation)
# Nur eine Anzeige fuer den Hauptdienst - Wants=/After=, NICHT Requires=/BindsTo=: ein # Nur eine Anzeige fuer den Hauptdienst - After=, NICHT Requires=/BindsTo=/Wants=: ein
# fehlendes oder abgestuerztes Panel darf server.py niemals mitreissen, und server.py # fehlendes oder abgestuerztes Panel darf server.py niemals mitreissen, und server.py
# darf umgekehrt starten, auch wenn dieser Dienst fehlt oder deaktiviert ist. Siehe # darf umgekehrt starten, auch wenn dieser Dienst fehlt oder deaktiviert ist. Siehe
# display.py's Modul-Docstring: die Anzeige ist nirgends Voraussetzung fuer die # display.py's Modul-Docstring: die Anzeige ist nirgends Voraussetzung fuer die
# Migration. # Migration.
#
# KEIN Wants=ninlight-station.service (stand hier zuerst, Fund der Hardware-Abnahme auf
# Pi 2): Wants= ist keine reine Reihenfolgeangabe, sondern AKTIVIERT die genannte Unit
# beim Start dieser Unit, unabhaengig davon, ob sie enabled ist - "ninlight-station" ist
# absichtlich disabled (siehe deren Kommentar: die Station ist per Vorgabe aus), wurde
# aber trotzdem bei jedem Boot mitgestartet, weil dieser (enabled) Anzeigedienst sie ueber
# Wants= mitzog. Ohne Zugangspunkt kann server.py 192.168.8.1 nicht binden, stirbt, wird
# von Restart=always dort mehrfach neu versucht und bleibt auf "failed" haengen - das
# flutet das Journal bei jedem Boot und laesst "ninlight-station status" faelschlich
# einen Fehler ("failed" statt "inactive") vermuten, wo absichtlich nichts laeuft.
# After= allein ordnet nur die Startreihenfolge, FALLS beide Units ohnehin gestartet
# werden (z.B. wenn jemand die Station von Hand einschaltet) - und genau das war hier
# gemeint, nicht "zieh die Station mit hoch". Nicht aus falscher Vorsicht wieder
# einbauen: der Nebeneffekt ist genau der oben beschriebene, kein Sicherheitsnetz.
After=ninlight-station.service After=ninlight-station.service
Wants=ninlight-station.service
# Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort standen sie zuerst - # Gehoeren seit systemd 229 ins [Unit], nicht ins [Service] (dort standen sie zuerst -
# Fixrunde 1, I1: systemd ignoriert sie dort mit "Unknown key name", die Vorgabe (5 # Fixrunde 1, I1: systemd ignoriert sie dort mit "Unknown key name", die Vorgabe (5
# Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten # Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten
+157 -5
View File
@@ -183,21 +183,173 @@ def test_hero_line_is_bigger_than_the_details():
def test_the_last_delivery_line_still_fits_on_the_panel(): def test_the_last_delivery_line_still_fits_on_the_panel():
"""Der vollste Zustand (Kopfbalken, grosse Zeile, sechs Beiwerkzeilen) muss """Der vollste Zustand (Kopfbalken mit Modus+Phase, grosse Zeile, fuenf
vollstaendig aufs Panel passen - mit groesserer Schrift ist das keine Beiwerkzeilen) muss vollstaendig aufs Panel passen - mit groesserer Schrift ist das
Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt keine Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt
ausgeliefert") faellt sonst stumm hinten herunter.""" ausgeliefert") faellt sonst stumm hinten herunter. Die Phase steckt seit der
zweiten Hardware-Abnahme im Kopfbalken (ROLE_HEADER_PHASE) statt als eigene
Beiwerkzeile - deshalb acht Bloecke insgesamt, aber nur noch fuenf Beiwerkzeilen
vor der letzten."""
state = _migration_state() state = _migration_state()
expected = render.lines(state) expected = render.lines(state)
assert len(expected) == 8, expected # sonst misst dieser Test etwas anderes assert len(expected) == 8, expected # sonst misst dieser Test etwas anderes
image = render.compose(state) image = render.compose(state)
last_line_top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX last_line_top = (render.HEADER_BAR_HEIGHT_PX + render.TEXT_MARGIN_PX
+ render.HERO_LINE_HEIGHT_PX + 5 * render.DETAIL_LINE_HEIGHT_PX) + render.HERO_LINE_HEIGHT_PX + 4 * render.DETAIL_LINE_HEIGHT_PX)
assert _ink_box(image, last_line_top, render.PANEL_HEIGHT_PX) is not None, ( assert _ink_box(image, last_line_top, render.PANEL_HEIGHT_PX) is not None, (
"letzte Zeile fehlt - sie passt nicht mehr aufs Panel") "letzte Zeile fehlt - sie passt nicht mehr aufs Panel")
def test_bottom_margin_stays_free_of_ink_even_in_the_fullest_state():
"""Zweite Hardware-Abnahme, Befund 2: der Rahmen des echten Panels frisst den
unteren Rand, der volleste Zustand wirkte dort gedraengt. Geprueft an echten
gezeichneten Pixeln (nicht an nachgerechneten Koordinaten): die letzten
BOTTOM_MARGIN_PX Zeilen duerfen im volleren Zustand keine einzige schwarze Tinte
enthalten, sonst waere der Abstand nur auf dem Papier vorhanden."""
image = render.compose(_migration_state())
pixels = image.load()
margin_top = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
ink_in_margin = any(
pixels[x, y] == 0
for y in range(margin_top, render.PANEL_HEIGHT_PX)
for x in range(render.PANEL_WIDTH_PX))
assert not ink_in_margin, (
f"Text reicht bis in die letzten {render.BOTTOM_MARGIN_PX}px - kein sichtbarer "
"Rand mehr")
def test_compose_stops_drawing_details_before_the_bottom_margin(monkeypatch):
"""Direkter Test der Abbruchbedingung in compose(): der heutige volleste Zustand
(fuenf Beiwerkzeilen) erreicht die 122 px ohnehin nicht mehr (s.o.) - eine einzelne
Zeile weniger oder mehr an der Faltkante der Abbruchbedingung ist dort nur 1-2px
Tinte, kein verlässlicher Ausschlag. Dieser Test ersetzt blocks() deshalb durch eine
synthetische Liste mit zehn Beiwerkzeilen - deutlich mehr, als aufs Panel passen -
und ZAEHLT die tatsaechlich gezeichneten Zeilen (als getrennte Tinten-Cluster
unterhalb des Kopfbalkens), statt nur zu prüfen, ob irgendein Pixel in den letzten
Zeilen liegt: mit der Abbruchbedingung "> PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX" werden
hier sechs Zeilen gezeichnet, ohne den reservierten Rand ("> PANEL_HEIGHT_PX" allein,
die Mutation) waeren es sieben - am Geraet nachgemessen (siehe Mutationsprobe im
Abnahmebericht)."""
synthetic = [("MIGRATION", render.ROLE_HEADER)] + [
(f"Zeile {i}", render.ROLE_DETAIL) for i in range(10)]
monkeypatch.setattr(render, "blocks", lambda state: synthetic)
image = render.compose({"mode": "migration"})
pixels = image.load()
rows_with_ink = [
any(pixels[x, y] == 0 for x in range(render.PANEL_WIDTH_PX))
for y in range(render.HEADER_BAR_HEIGHT_PX, render.PANEL_HEIGHT_PX)]
drawn_lines = sum(
1 for i, has_ink in enumerate(rows_with_ink)
if has_ink and not (i > 0 and rows_with_ink[i - 1]))
assert drawn_lines == 6, (
f"{drawn_lines} Beiwerkzeilen gezeichnet, sechs erwartet - "
"BOTTOM_MARGIN_PX wird in der Abbruchbedingung nicht mehr beruecksichtigt")
margin_top = render.PANEL_HEIGHT_PX - render.BOTTOM_MARGIN_PX
ink_in_margin = any(
pixels[x, y] == 0
for y in range(margin_top, render.PANEL_HEIGHT_PX)
for x in range(render.PANEL_WIDTH_PX))
assert not ink_in_margin, (
"eine ueberlaufende Beiwerkzeile wurde trotzdem in den reservierten unteren "
"Rand gezeichnet")
# --- Kopfzeile: Phase neben dem Modus (zweite Hardware-Abnahme, Befund 1) -----------
#
# Frueher stand die Phase als eigene kleine Beiwerkzeile unter dem Lampennamen und ging
# dort unter. Jetzt steht sie rechtsbuendig im selben schwarzen Balken wie der Modus,
# der dafuer nach links ruecken darf. Rettung/aus kennen keine Phase und behalten die
# alte, zentrierte Kopfzeile.
def _header_white_columns(image):
"""Spalten (x) im Kopfbalken, die irgendwo eine weisse (Text-)Zelle enthalten."""
pixels = image.load()
return [x for x in range(render.PANEL_WIDTH_PX)
if any(pixels[x, y] == 255 for y in range(render.HEADER_BAR_HEIGHT_PX))]
def test_phase_appears_right_aligned_next_to_the_mode_in_the_header():
"""Grundfall: Modus links, Phase rechts, beide im selben Balken sichtbar - nicht mehr
als eigene Zeile darunter."""
state = _migration_state()
state["phase"] = "auto"
del state["armed_target"]
lines_out = render.lines(state)
assert lines_out[0] == "MIGRATION"
assert lines_out[1] == "AUTO"
image = render.compose(state)
white_cols = _header_white_columns(image)
assert white_cols, "Kopfzeile zeigt keinen Text"
# Modus (links) und Phase (rechts): die Schrift muss deutlich in der linken UND in
# der rechten Panelhaelfte stehen - stuende alles zentriert (die alte Darstellung),
# waere die rechte Haelfte praktisch leer.
left_half = [x for x in white_cols if x < render.PANEL_WIDTH_PX // 2]
right_half = [x for x in white_cols if x >= render.PANEL_WIDTH_PX // 2]
assert left_half, "kein Modus-Text in der linken Kopfzeilenhaelfte"
assert right_half, "keine Phase in der rechten Kopfzeilenhaelfte"
# Und die Phase muss tatsaechlich an den rechten Rand reichen (rechtsbuendig).
assert max(white_cols) >= render.PANEL_WIDTH_PX - render.TEXT_MARGIN_PX - 10
def test_recovery_header_stays_centered_without_a_phase():
"""Rettung kennt keine Phase - die Kopfzeile bleibt wie vor der zweiten
Hardware-Abnahme: der Modus steht waagerecht mittig, nicht links."""
image = render.compose({"mode": "recovery", "ssid": "ninlight-recovery",
"served": 0, "recent": []})
white_cols = _header_white_columns(image)
assert white_cols
left_gap = white_cols[0]
right_gap = render.PANEL_WIDTH_PX - 1 - white_cols[-1]
assert abs(left_gap - right_gap) <= 3, (
f"Modus nicht mehr mittig (links {left_gap}, rechts {right_gap}) - "
"Rettung darf die Kopfzeile nicht umbauen")
def test_long_armed_target_does_not_overlap_the_mode_in_the_header():
"""Mutationswache fuer Befund 1: eine sehr lange Phasenbezeichnung ("FREIGEGEBEN:
<langer Zielname>") darf den Modus links nicht ueberdecken. Geprueft an echten
Pixeln, nicht an Koordinaten: zwischen dem Ende des Modus-Texts und dem Anfang der
Phase muss eine durchgehend schwarze Luecke bleiben, die deutlich breiter ist als
eine normale Buchstabenluecke innerhalb eines Worts (die bei einer echten
Ueberlappung/einem fehlenden Kuerzungs-Clamp verschwinden wuerde, weil die Phase
dann weit nach links unter/ueber den Modus gezeichnet wird)."""
state = _migration_state()
state["phase"] = "armed"
state["armed_target"] = "x" * 80
image = render.compose(state)
pixels = image.load()
header_rows = range(render.HEADER_BAR_HEIGHT_PX)
white_cols = [x for x in range(render.PANEL_WIDTH_PX)
if any(pixels[x, y] == 255 for y in header_rows)]
assert white_cols
def is_black_column(x):
return all(pixels[x, y] != 255 for y in header_rows)
first, last = white_cols[0], white_cols[-1]
best_gap = 0
run = 0
for x in range(first, last + 1):
if is_black_column(x):
run += 1
best_gap = max(best_gap, run)
else:
run = 0
assert best_gap >= render.HEADER_PHASE_GAP_PX - 1, (
f"kein durchgehender Zwischenraum von mindestens "
f"{render.HEADER_PHASE_GAP_PX - 1}px zwischen Modus und Phase gefunden "
f"(groesste Luecke: {best_gap}px) - Modus und Phase ueberlappen sich"
)
def test_compose_still_works_without_any_font_file(monkeypatch): def test_compose_still_works_without_any_font_file(monkeypatch):
"""Die Zusage "eine fehlende Schrift haelt die Anzeige nicht auf" gilt unveraendert: """Die Zusage "eine fehlende Schrift haelt die Anzeige nicht auf" gilt unveraendert:
ohne einen einzigen Treffer in der Suchkette faellt compose() auf load_default() ohne einen einzigen Treffer in der Suchkette faellt compose() auf load_default()
+20
View File
@@ -719,6 +719,26 @@ else
fail "ninlight-display.service wurde nicht installiert - Szenario 19 kann nichts pruefen" fail "ninlight-display.service wurde nicht installiert - Szenario 19 kann nichts pruefen"
fi fi
echo "=== Szenario 20 (Hardware-Abnahme, Pi 2): Wants= zieht den Stationsdienst nicht mehr mit hoch ==="
# Befund am Geraet: ninlight-station ist absichtlich disabled (die Station ist per
# Vorgabe aus), wurde aber bei jedem Boot trotzdem gestartet, weil dieser (enabled)
# Anzeigedienst per Wants=ninlight-station.service die andere Unit AKTIVIERT hat - Wants=
# ordnet nicht nur, es startet die genannte Unit mit. Ohne Zugangspunkt kann server.py
# 192.168.8.1 nicht binden, stirbt, wird von Restart=always mehrfach neu versucht und
# bleibt auf "failed" haengen (flutet das Journal bei jedem Boot). Gemeint war reine
# Reihenfolge - dafuer reicht After= allein, das nur ordnet, FALLS beide Units ohnehin
# starten, und aktiviert nichts von sich aus.
if [ -f "$INSTALLED_DISPLAY_UNIT" ]; then
grep -Eq '^Wants=ninlight-station\.service$' "$INSTALLED_DISPLAY_UNIT" \
&& fail "Wants=ninlight-station.service steht noch in der ausgelieferten Unit - das zieht die (absichtlich disabled) Station bei jedem Boot mit hoch" \
|| pass "kein Wants=ninlight-station.service mehr in der ausgelieferten Unit"
grep -Eq '^After=ninlight-station\.service$' "$INSTALLED_DISPLAY_UNIT" \
&& pass "After=ninlight-station.service bleibt (reine Reihenfolge, falls beide ohnehin starten)" \
|| fail "After=ninlight-station.service fehlt - die Startreihenfolge waere nicht mehr garantiert"
else
fail "ninlight-display.service wurde nicht installiert - Szenario 20 kann nichts pruefen"
fi
echo echo
if [ "$FAILED" -eq 0 ]; then if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden." echo "Alle Tests bestanden."