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:
@@ -35,6 +35,15 @@ try:
|
||||
except ImportError: # pragma: no cover - dito
|
||||
spidev = None
|
||||
|
||||
# lgpio ist die Pin-Factory, die gpiozero auf beiden Pis benutzt (Debian 13 trixie wie
|
||||
# Bookworm). Sie meldet einen belegten Pin nicht als gpiozero-Ausnahme, sondern als
|
||||
# lgpio.error - siehe _hardware_error_types(). Auf der Entwicklungsmaschine fehlt das
|
||||
# Modul; dann bleibt es None und die Fehlerliste kommt ohne es aus.
|
||||
try:
|
||||
import lgpio
|
||||
except ImportError: # pragma: no cover - auf dem Pi immer vorhanden
|
||||
lgpio = None
|
||||
|
||||
|
||||
class DisplayUnavailable(Exception):
|
||||
"""Kein Panel ansprechbar: Bibliothek fehlt, /dev/spidev0.0 fehlt, oder der
|
||||
@@ -89,6 +98,52 @@ def spi_writebyte2(data) -> None:
|
||||
_spi.writebytes2(data)
|
||||
|
||||
|
||||
def _busy_hint(error: BaseException) -> str:
|
||||
"""Zusatz fuer die Fehlermeldung, wenn die Leitungen belegt sind. "GPIO busy" heisst
|
||||
auf dieser Station praktisch immer "der Anzeigedienst haelt die Pins" - und trifft
|
||||
ausgerechnet den Befehl, den der Hilfetext als ersten Handgriff der Abnahme nennt.
|
||||
Die Meldung soll den Bediener also nicht zum Suchen schicken, sondern sagen, was los
|
||||
ist. Leer, wenn es nicht danach aussieht - ein erfundener Hinweis waere schlimmer als
|
||||
keiner."""
|
||||
if "busy" in str(error).lower():
|
||||
return (" - haelt der Anzeigedienst die Leitungen? "
|
||||
"Pruefen mit 'systemctl status ninlight-display'")
|
||||
return ""
|
||||
|
||||
|
||||
def _hardware_error_types() -> tuple:
|
||||
"""Die Ausnahmen, die als "keine/blockierte Hardware" gelten und damit zu
|
||||
DisplayUnavailable werden. Bewusst eine ENUMERATION, kein `except Exception`: ein
|
||||
echter Programmierfehler an anderer Stelle (TypeError, AttributeError, NameError)
|
||||
soll weiterhin als das erkennbar bleiben, was er ist, statt hier als "Panel fehlt"
|
||||
getarnt zu werden (Fixrunde 1, C2, zweiter Teil).
|
||||
|
||||
gpiozero.GPIOZeroError (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-
|
||||
Familie (PinInvalidPin, PinUnsupported, ... - z.B. bei falscher/fehlender
|
||||
Pin-Factory-Zuordnung) haengt direkt unter GPIOZeroError, nicht unter
|
||||
GPIODeviceError - per `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory
|
||||
erbt zusaetzlich von ImportError und war dadurch schon abgedeckt.
|
||||
|
||||
lgpio.error (Hardware-Abnahme): laeuft der Anzeigedienst, haelt er die GPIO-Leitungen,
|
||||
und ein gleichzeitiges "ninlight-station display test" bekommt beim Anlegen des ersten
|
||||
Pins ein `lgpio.error: 'GPIO busy'`. Das ist KEINE gpiozero.GPIOZeroError - gpiozero
|
||||
reicht die Ausnahme seiner Pin-Factory unveraendert durch - und fiel deshalb durch
|
||||
diese Liste hindurch: statt der vorgesehenen einen Protokollzeile gab es einen rohen
|
||||
Stapelabzug und rc=1. lgpio.error erbt direkt von Exception, die drei
|
||||
Programmierfehler oben bleiben davon also unberuehrt.
|
||||
|
||||
Wird bei JEDEM Aufruf neu gebaut statt einmal beim Import: nur so wirkt ein
|
||||
monkeypatch.setattr(epdconfig, "lgpio", ...) im Test ueberhaupt (dasselbe Muster wie
|
||||
bei gpiozero/spidev in tools/station/tests/test_render.py)."""
|
||||
types: tuple = (OSError, ImportError)
|
||||
if gpiozero is not None:
|
||||
types = types + (gpiozero.GPIOZeroError,)
|
||||
lgpio_error = getattr(lgpio, "error", None) if lgpio is not None else None
|
||||
if isinstance(lgpio_error, type) and issubclass(lgpio_error, BaseException):
|
||||
types = types + (lgpio_error,)
|
||||
return types
|
||||
|
||||
|
||||
def module_init() -> int:
|
||||
"""Oeffnet GPIO-Pins und SPI-Geraet. Wirft DisplayUnavailable statt einer rohen
|
||||
Bibliotheksausnahme, damit display.py genau eine Ausnahmeklasse abfangen muss.
|
||||
@@ -114,18 +169,7 @@ def module_init() -> int:
|
||||
if not os.path.exists(spi_path):
|
||||
raise DisplayUnavailable(f"{spi_path} fehlt - ist SPI aktiviert (raspi-config)?")
|
||||
|
||||
# Nur Fehler, die tatsaechlich von einer fehlenden/blockierten Hardware kommen, werden
|
||||
# zu DisplayUnavailable - alles andere (ein echter Programmierfehler an anderer
|
||||
# Stelle) soll weiterhin als das erkennbar bleiben, was es ist, statt hier als
|
||||
# "Panel fehlt" getarnt zu werden (Fixrunde 1, C2, zweiter Teil). gpiozero.GPIOZeroError
|
||||
# (nicht nur GPIODeviceError, Fixrunde 2, N4): die PinError-Familie (PinInvalidPin,
|
||||
# PinUnsupported, ... - z.B. bei falscher/fehlender Pin-Factory-Zuordnung) haengt
|
||||
# direkt unter GPIOZeroError, nicht unter GPIODeviceError - per
|
||||
# `gpiozero.PinInvalidPin.__mro__` nachgeprueft. BadPinFactory erbt zusaetzlich von
|
||||
# ImportError und war dadurch schon abgedeckt.
|
||||
hardware_errors: tuple[type[BaseException], ...] = (OSError, ImportError)
|
||||
if gpiozero is not None:
|
||||
hardware_errors = hardware_errors + (gpiozero.GPIOZeroError,)
|
||||
hardware_errors = _hardware_error_types()
|
||||
|
||||
try:
|
||||
_rst_pin = gpiozero.DigitalOutputDevice(RST_PIN)
|
||||
@@ -138,7 +182,8 @@ def module_init() -> int:
|
||||
_spi.mode = 0b00
|
||||
except hardware_errors as e:
|
||||
module_exit()
|
||||
raise DisplayUnavailable(f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}") from e
|
||||
raise DisplayUnavailable(
|
||||
f"GPIO/SPI-Initialisierung fehlgeschlagen: {e}{_busy_hint(e)}") from e
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
+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