diff --git a/tools/station/display/render.py b/tools/station/display/render.py index c45177c..59aa7b7 100644 --- a/tools/station/display/render.py +++ b/tools/station/display/render.py @@ -46,32 +46,64 @@ TEXT_MARGIN_PX = 2 # aus zwei Metern nicht mehr lesbar (Befund Michaels am Geraet). blocks() vergibt # deshalb jeder Zeile eine ROLLE, und compose() setzt jede Rolle anders: # -# header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle Panelbreite. -# Invertiert kostet auf e-Paper nichts (bistabil, kein Stromverbrauch pro -# Pixel) und trennt die Kopfzeile auf einen Blick vom Rest. -# 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. +# header - Modus, fett, WEISS AUF SCHWARZEM BALKEN ueber die volle +# Panelbreite. Invertiert kostet auf e-Paper nichts (bistabil, kein +# Stromverbrauch pro Pixel) und trennt die Kopfzeile auf einen Blick +# vom Rest. +# header_phase - die Phase (AUTO/HORCHT/FREIGEGEBEN: ...), NUR im Migrationsmodus und +# nur, wenn eine Phase bekannt ist. Steht in derselben Kopfzeile wie +# header, rechtsbuendig, in kleinerer Schrift (zweite Hardware-Abnahme: +# 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_PHASE = "header_phase" ROLE_HERO = "hero" ROLE_DETAIL = "detail" ROLE_BIG = "big" HEADER_FONT_PX = 20 +HEADER_PHASE_FONT_PX = 13 HERO_FONT_PX = 16 DETAIL_FONT_PX = 11 BIG_FONT_PX = 26 # Zeilenhoehen so gewaehlt, dass der vollste Zustand (Migration mit erkannter Lampe: -# Kopfbalken + grosse Zeile + sechs Beiwerkzeilen) exakt auf die 122 px passt und die -# letzte Zeile - "zuletzt ausgeliefert" - nicht mehr hinten abfaellt: -# 22 + 2 + 20 + 6*13 = 122. +# Kopfbalken + grosse Zeile + fuenf Beiwerkzeilen) auf die 122 px passt, mit sichtbarem +# Abstand nach unten uebrig (naechster Kommentar, BOTTOM_MARGIN_PX): +# 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 HERO_LINE_HEIGHT_PX = 20 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: ") +# 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 # (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 @@ -152,9 +184,13 @@ def blocks(state: dict) -> list[tuple[str, str]]: ssid = state.get("ssid") if mode == "recovery": - # Im Rettungsmodus ist die SSID 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. + # Im Rettungsmodus gibt es keine Phase - kein ROLE_HEADER_PHASE-Block, die + # Kopfzeile bleibt also nur der Modus (compose() zentriert sie dann weiterhin + # 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: out.append((str(ssid), ROLE_HERO)) 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 # Teil des zusammengefuehrten Zustands herein. Im Rettungsmodus gibt es keine # 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") if not isinstance(last_seen, dict): last_seen = {} @@ -176,13 +225,6 @@ def blocks(state: dict) -> list[tuple[str, str]]: elif ssid: 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: target = last_seen.get("target") 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() -def _draw_centered(draw, text: str, font, top: int, height: int, fill: int) -> None: - """Zeichnet `text` waagerecht mittig aufs Panel und senkrecht mittig in das Band - [top, top+height). 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.""" +def _draw_text(draw, text: str, font, top: int, height: int, fill: int, + align: str = "center") -> int: + """Zeichnet `text` senkrecht mittig in das Band [top, top+height) und waagerecht + nach `align`: "center" (Panelmitte - bisheriges Verhalten, fuer ROLE_BIG und die + Kopfzeile ohne Phase), "left" (linker Rand + TEXT_MARGIN_PX - der Modus, sobald eine + 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) - 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] - 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): @@ -285,13 +343,14 @@ def compose(state: dict): draw = ImageDraw.Draw(image) fonts = { 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_DETAIL: _load_font(ImageFont, DETAIL_FONT_PX), ROLE_BIG: _load_font(ImageFont, BIG_FONT_PX, bold=True), } 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"), # sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von # main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3, @@ -302,28 +361,59 @@ def compose(state: dict): text = raw_text.replace("\n", " ") return _truncate_to_width( 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 - for raw_text, role in blocks(state): + for raw_text, role in block_list: font = fonts[role] - text = fit(raw_text, font) if role == ROLE_BIG: # Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze # 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 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_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 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 - if y + line_height > PANEL_HEIGHT_PX: - # Passt nicht mehr aufs Panel - abschneiden statt ueberzeichnen. Die + if y + line_height > PANEL_HEIGHT_PX - BOTTOM_MARGIN_PX: + # 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. break draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0) diff --git a/tools/station/ninlight-display.service b/tools/station/ninlight-display.service index 7415261..d42a1ad 100644 --- a/tools/station/ninlight-display.service +++ b/tools/station/ninlight-display.service @@ -1,12 +1,25 @@ [Unit] 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 # darf umgekehrt starten, auch wenn dieser Dienst fehlt oder deaktiviert ist. Siehe # display.py's Modul-Docstring: die Anzeige ist nirgends Voraussetzung fuer die # 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 -Wants=ninlight-station.service # 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 # Starts/10s) greift dann bei RestartSec=5 nie, und genau das sollte die Deckelung unten diff --git a/tools/station/tests/test_render.py b/tools/station/tests/test_render.py index 59c1f4a..20b5f91 100644 --- a/tools/station/tests/test_render.py +++ b/tools/station/tests/test_render.py @@ -183,21 +183,173 @@ def test_hero_line_is_bigger_than_the_details(): def test_the_last_delivery_line_still_fits_on_the_panel(): - """Der vollste Zustand (Kopfbalken, grosse Zeile, sechs Beiwerkzeilen) muss - vollstaendig aufs Panel passen - mit groesserer Schrift ist das keine - Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt - ausgeliefert") faellt sonst stumm hinten herunter.""" + """Der vollste Zustand (Kopfbalken mit Modus+Phase, grosse Zeile, fuenf + Beiwerkzeilen) muss vollstaendig aufs Panel passen - mit groesserer Schrift ist das + keine Selbstverstaendlichkeit mehr, und ausgerechnet die letzte Zeile ("zuletzt + 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() expected = render.lines(state) assert len(expected) == 8, expected # sonst misst dieser Test etwas anderes image = render.compose(state) 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, ( "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: + ") 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): """Die Zusage "eine fehlende Schrift haelt die Anzeige nicht auf" gilt unveraendert: ohne einen einzigen Treffer in der Suchkette faellt compose() auf load_default() diff --git a/tools/station/tests/test_setup_pi.sh b/tools/station/tests/test_setup_pi.sh index 2f3b398..e921dc3 100755 --- a/tools/station/tests/test_setup_pi.sh +++ b/tools/station/tests/test_setup_pi.sh @@ -719,6 +719,26 @@ else fail "ninlight-display.service wurde nicht installiert - Szenario 19 kann nichts pruefen" 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 if [ "$FAILED" -eq 0 ]; then echo "Alle Tests bestanden."