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:
+127
-37
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user