feat(display): grosse Schrift, invertierter Kopf, GPIO-Uebergabe
Zwei Befunde der Hardware-Abnahme. 1) Die Schrift war zu klein: compose() zeichnete alles in ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big), compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem Balken, die wichtigste Zeile gross (Migration: erkannte Lampe, Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig. Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt load_default() ohne Argument, damit eine fehlende Schrift die Anzeige (und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow importierbar, die Breitenkuerzung misst weiter in Pixeln. 2) "display test" scheiterte bei laufendem Anzeigedienst mit einem rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler - und die Meldung nennt bei belegten Leitungen den Anzeigedienst. "display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs und startet ihn danach wieder, aber nur, wenn er vorher lief.
This commit is contained in:
+206
-43
@@ -1,11 +1,16 @@
|
||||
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
|
||||
|
||||
lines() ist bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe
|
||||
blocks()/lines() sind bewusst ohne Pillow und ohne Hardwarezugriff importierbar und testbar (siehe
|
||||
tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb der
|
||||
Funktion, damit ein fehlendes PIL (das erst per "apt install python3-pil" auf den Pi
|
||||
kommt, siehe setup_pi.sh) render.py selbst nicht unbenutzbar macht.
|
||||
|
||||
Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. lines() liefert
|
||||
Die Schrift kommt aus FONT_SEARCH_PATHS (DejaVu auf beiden Pis) statt aus
|
||||
ImageFont.load_default(): default ist rund 10 px hoch und auf 250x122 aus zwei Metern
|
||||
nicht lesbar (Befund der Hardware-Abnahme) - siehe _load_font(), warum der
|
||||
naheliegende Weg load_default(size=...) auf Pi 2 (Pillow 9.4.0) ausscheidet.
|
||||
|
||||
Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. blocks() liefert
|
||||
UNGEKUERZTE Zeilen - die Breitenkuerzung (mit "..." statt Umbruch, Spec: eine zu lange
|
||||
Zeile darf das Layout nicht sprengen) passiert erst in compose(), per _truncate_to_width()
|
||||
gegen die tatsaechliche, schriftabhaengige Pixelbreite (PIL.ImageFont.load_default() ist
|
||||
@@ -33,7 +38,72 @@ from typing import Callable
|
||||
PANEL_WIDTH_PX = 250
|
||||
PANEL_HEIGHT_PX = 122
|
||||
TEXT_MARGIN_PX = 2
|
||||
LINE_HEIGHT_PX = 14
|
||||
|
||||
# --- Rollen und ihre Groessen ------------------------------------------------------
|
||||
#
|
||||
# Bis zur Hardware-Abnahme zeichnete compose() alle Zeilen in derselben
|
||||
# ImageFont.load_default() (rund 10 px hoch) - auf 250x122 war das Kleingedrucktes und
|
||||
# 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.
|
||||
ROLE_HEADER = "header"
|
||||
ROLE_HERO = "hero"
|
||||
ROLE_DETAIL = "detail"
|
||||
ROLE_BIG = "big"
|
||||
|
||||
HEADER_FONT_PX = 20
|
||||
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.
|
||||
HEADER_BAR_HEIGHT_PX = 22
|
||||
HERO_LINE_HEIGHT_PX = 20
|
||||
DETAIL_LINE_HEIGHT_PX = 13
|
||||
|
||||
# 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
|
||||
# dort wirft load_default(size=...) TypeError ("unexpected keyword argument 'size'"), am
|
||||
# Geraet gegengeprueft. Deshalb fuehrt der Hauptweg ueber ImageFont.truetype() auf eine
|
||||
# Datei im Dateisystem: /usr/share/fonts/truetype/dejavu/ liegt auf BEIDEN Pis
|
||||
# (Debian 13 trixie wie Bookworm) und ist damit der einzige Pfad, auf den es im Betrieb
|
||||
# ankommt. Alles darunter existiert nur, damit compose() auch auf Rechnern ohne den
|
||||
# Debian-Pfad eine skalierbare Schrift findet - insbesondere auf der macOS-
|
||||
# Entwicklungsmaschine, wo tools/station/tests/test_render.py laufen muss.
|
||||
#
|
||||
# Letzter Rueckfall (in _load_font(), nicht in dieser Liste) ist load_default() OHNE
|
||||
# Argument: dann ist die Schrift wieder klein, aber nichts stuerzt ab - die Zusage
|
||||
# "eine fehlende Schrift darf die Anzeige (und damit nie eine Migration) aufhalten"
|
||||
# gilt unveraendert.
|
||||
FONT_SEARCH_PATHS = (
|
||||
# Debian/Raspberry Pi OS - der Betriebsfall auf beiden Pis.
|
||||
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
|
||||
# Andere Linux-Distributionen legen dieselbe Schrift woanders ab.
|
||||
("/usr/share/fonts/dejavu/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf"),
|
||||
("/usr/share/fonts/TTF/DejaVuSans.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"),
|
||||
# DejaVu per Homebrew (macOS), falls vorhanden.
|
||||
("/opt/homebrew/share/fonts/DejaVuSans.ttf",
|
||||
"/opt/homebrew/share/fonts/DejaVuSans-Bold.ttf"),
|
||||
# macOS-Bordmittel - nur fuer die Entwicklungsmaschine, nie fuer den Pi.
|
||||
("/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf"),
|
||||
("/Library/Fonts/Arial.ttf", "/Library/Fonts/Arial Bold.ttf"),
|
||||
)
|
||||
|
||||
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
||||
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
|
||||
@@ -49,74 +119,103 @@ def _hhmm(ts: object) -> str:
|
||||
return "--:--"
|
||||
|
||||
|
||||
def lines(state: dict) -> list[str]:
|
||||
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen fuers Panel (siehe
|
||||
Modul-Docstring, warum die Kuerzung hier bewusst nicht passiert). Wirft nie -
|
||||
fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen."""
|
||||
def blocks(state: dict) -> list[tuple[str, str]]:
|
||||
"""Zerlegt den zusammengefuehrten Zustand in UNGEKUERZTE Textzeilen mit ihrer ROLLE
|
||||
(siehe Rollen-Kommentar oben, und den Modul-Docstring, warum die Kuerzung hier bewusst
|
||||
nicht passiert). Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt
|
||||
eine Ausnahme zu werfen.
|
||||
|
||||
Bleibt wie lines() ohne Pillow importierbar und testbar: hier entsteht nur Text, kein
|
||||
Bild - die Rolle ist ein reiner Bezeichner, die zugehoerigen Schriftgroessen kennt
|
||||
ausschliesslich compose()."""
|
||||
state = state if isinstance(state, dict) else {}
|
||||
mode = state.get("mode")
|
||||
out: list[str] = []
|
||||
out: list[tuple[str, str]] = []
|
||||
|
||||
if mode == "off":
|
||||
# Die Station ist komplett aus (kein status.json vorhanden) - display.py setzt
|
||||
# diesen Modus selbst, server.py kennt ihn nicht. Bewusst keine weiteren Zeilen
|
||||
# (insbesondere keine SSID - die waere aus einer alten Konfigurationsdatei und
|
||||
# gerade nicht aktiv).
|
||||
out.append("Station aus")
|
||||
# gerade nicht aktiv). Genau deshalb ROLE_BIG: die Zeile ist der ganze Inhalt.
|
||||
out.append(("Station aus", ROLE_BIG))
|
||||
return out
|
||||
|
||||
if mode not in ("recovery", "migration"):
|
||||
# Unbekannter oder fehlender Modus, z.B. ein frisch aufgesetzter Pi, auf dem noch
|
||||
# nichts geschrieben wurde. Lieber ehrlich "unbekannt" zeigen als einen Modus zu
|
||||
# unterstellen.
|
||||
out.append(f"Status unbekannt ({mode})" if mode else "Status unbekannt")
|
||||
out.append((f"Status unbekannt ({mode})" if mode else "Status unbekannt", ROLE_BIG))
|
||||
return out
|
||||
|
||||
out.append(MODE_LABELS[mode])
|
||||
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
||||
|
||||
ssid = state.get("ssid")
|
||||
if ssid:
|
||||
out.append(f"SSID: {ssid}")
|
||||
|
||||
if mode == "migration":
|
||||
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.
|
||||
if ssid:
|
||||
out.append((str(ssid), ROLE_HERO))
|
||||
else:
|
||||
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
|
||||
# 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.
|
||||
phase = state.get("phase")
|
||||
if phase == "armed":
|
||||
target = state.get("armed_target") or "?"
|
||||
out.append(f"FREIGEGEBEN: {target}")
|
||||
elif phase in PHASE_LABELS:
|
||||
out.append(PHASE_LABELS[phase])
|
||||
|
||||
last_seen = state.get("last_seen")
|
||||
if not isinstance(last_seen, dict):
|
||||
last_seen = {}
|
||||
product_name = last_seen.get("product_name")
|
||||
|
||||
# Reihenfolge bewusst anders als frueher (da stand die SSID als zweite Zeile):
|
||||
# gross kommt das, was sich gerade aendert und worauf man wartet - die erkannte
|
||||
# Lampe. Solange keine erkannt ist, ruecke die SSID nach oben, damit die grosse
|
||||
# Zeile nie leer bleibt.
|
||||
if product_name:
|
||||
out.append((str(product_name), ROLE_HERO))
|
||||
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:
|
||||
out.append(str(product_name))
|
||||
target = last_seen.get("target")
|
||||
variant = last_seen.get("variant")
|
||||
if target or variant:
|
||||
out.append(f"{target or '?'} ({variant or '?'})")
|
||||
out.append((f"{target or '?'} ({variant or '?'})", ROLE_DETAIL))
|
||||
lamp_version = last_seen.get("lamp_version")
|
||||
if lamp_version:
|
||||
out.append(f"FW: {lamp_version}")
|
||||
out.append((f"FW: {lamp_version}", ROLE_DETAIL))
|
||||
if ssid:
|
||||
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
|
||||
# SSID zweimal auf dem Panel.
|
||||
out.append((f"SSID: {ssid}", ROLE_DETAIL))
|
||||
|
||||
served = state.get("served")
|
||||
out.append(f"Ausgeliefert: {served if isinstance(served, int) else 0}")
|
||||
out.append((f"Ausgeliefert: {served if isinstance(served, int) else 0}", ROLE_DETAIL))
|
||||
|
||||
recent = state.get("recent")
|
||||
if isinstance(recent, list) and recent:
|
||||
last = recent[-1]
|
||||
if isinstance(last, dict):
|
||||
out.append(f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
|
||||
f"{last.get('status') or '?'}")
|
||||
out.append((f"{_hhmm(last.get('ts'))} {last.get('target') or '?'} "
|
||||
f"{last.get('status') or '?'}", ROLE_DETAIL))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def lines(state: dict) -> list[str]:
|
||||
"""Nur die Texte aus blocks(), ohne Rollen - unveraenderte Schnittstelle fuer
|
||||
display.run_daemon(), das damit erkennt, ob sich der Inhalt geaendert hat (die Rolle
|
||||
ist dafuer unerheblich, sie haengt nur am Modus). Wie blocks() ohne Pillow benutzbar."""
|
||||
return [text for text, _role in blocks(state)]
|
||||
|
||||
|
||||
def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px: float) -> str:
|
||||
"""Kuerzt `text` mit "…" am Ende, bis `measure(text)` nicht mehr breiter als
|
||||
`max_width_px` ist - nie umgebrochen, immer von rechts gekuerzt.
|
||||
@@ -137,33 +236,97 @@ def _truncate_to_width(text: str, measure: Callable[[str], float], max_width_px:
|
||||
return (trimmed + ellipsis) if trimmed else ellipsis
|
||||
|
||||
|
||||
def _load_font(image_font, size_px: int, bold: bool = False):
|
||||
"""Laedt eine skalierbare Schrift der Groesse `size_px` aus FONT_SEARCH_PATHS - der
|
||||
erste Pfad, der sich oeffnen laesst, gewinnt.
|
||||
|
||||
`image_font` ist das PIL.ImageFont-MODUL, hereingereicht statt hier importiert: so
|
||||
bleibt render.py (ausserhalb von compose()) ohne Pillow importierbar - dasselbe
|
||||
Muster wie die Messfunktion in _truncate_to_width() - und die Kette laesst sich mit
|
||||
einem nachgebauten Modul pruefen, ohne Schriftdateien anzulegen oder Pillow
|
||||
herunterzustufen.
|
||||
|
||||
Gefangen wird OSError (Datei fehlt/unlesbar/kein gueltiges Schriftformat) UND
|
||||
ImportError: ein ohne FreeType gebautes Pillow laesst truetype() mit genau dieser
|
||||
Ausnahme scheitern - dann darf die Anzeige nicht ausfallen, sondern faellt bis auf
|
||||
load_default() durch.
|
||||
|
||||
load_default() wird BEWUSST OHNE size-Argument gerufen: das gibt es erst ab
|
||||
Pillow 10.1, Pi 2 hat 9.4.0 (siehe FONT_SEARCH_PATHS). Ein `size=`-Aufruf waere dort
|
||||
ein TypeError mitten im Zeichnen - also genau in dem Pfad, der eigentlich der letzte
|
||||
Rettungsanker ist."""
|
||||
for regular_path, bold_path in FONT_SEARCH_PATHS:
|
||||
path = bold_path if bold else regular_path
|
||||
try:
|
||||
return image_font.truetype(path, size_px)
|
||||
except (OSError, ImportError):
|
||||
continue
|
||||
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."""
|
||||
bbox = draw.textbbox((0, 0), text, font=font)
|
||||
x = (PANEL_WIDTH_PX - (bbox[2] - bbox[0])) // 2 - bbox[0]
|
||||
y = top + (height - (bbox[3] - bbox[1])) // 2 - bbox[1]
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
|
||||
def compose(state: dict):
|
||||
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus lines().
|
||||
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus blocks().
|
||||
Importiert Pillow erst hier - siehe Modul-Docstring."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
fonts = {
|
||||
ROLE_HEADER: _load_font(ImageFont, HEADER_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 measure(text: str) -> float:
|
||||
return draw.textlength(text, font=font)
|
||||
|
||||
y = TEXT_MARGIN_PX
|
||||
for raw_line in lines(state):
|
||||
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
|
||||
break
|
||||
def fit(raw_text: str, font) -> 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,
|
||||
# F5). lines() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
|
||||
# F5). blocks() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
|
||||
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
|
||||
# Wert, den lines() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
|
||||
# Wert, den blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
|
||||
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
|
||||
line = raw_line.replace("\n", " ")
|
||||
fitted = _truncate_to_width(line, measure=measure, max_width_px=max_width_px)
|
||||
draw.text((TEXT_MARGIN_PX, y), fitted, font=font, fill=0)
|
||||
y += LINE_HEIGHT_PX
|
||||
text = raw_text.replace("\n", " ")
|
||||
return _truncate_to_width(
|
||||
text, measure=lambda t: draw.textlength(t, font=font),
|
||||
max_width_px=max_width_px)
|
||||
|
||||
y = TEXT_MARGIN_PX
|
||||
for raw_text, role in blocks(state):
|
||||
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)
|
||||
continue
|
||||
|
||||
if role == ROLE_HEADER:
|
||||
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)
|
||||
y = HEADER_BAR_HEIGHT_PX + TEXT_MARGIN_PX
|
||||
continue
|
||||
|
||||
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
|
||||
# Reihenfolge in blocks() ist deshalb nach Wichtigkeit sortiert.
|
||||
break
|
||||
draw.text((TEXT_MARGIN_PX, y), text, font=font, fill=0)
|
||||
y += line_height
|
||||
|
||||
return image
|
||||
|
||||
Reference in New Issue
Block a user