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
|
||||
# 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: <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
|
||||
# (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)
|
||||
|
||||
Reference in New Issue
Block a user