'off' stoppte bisher den Anzeigedienst und leerte das Panel, aber kein 'on'-Pfad hat ihn je wieder gestartet - ab dem zweiten Ein/Aus blieb das Panel bis zum naechsten Pi-Neustart dunkel. Die Begruendung dafuer trug nicht: der Anzeigedienst erkennt den ausgeschalteten Zustand selbst (systemctl is-active ninlight-station) und zeichnet von sich aus "Station aus". 'off' fasst den Anzeigedienst jetzt nicht mehr an; er laeuft durchgehend (wie seit dem Booten ohnehin) und zeigt den jeweils aktuellen Zustand. display_off() bleibt fuer den expliziten Diagnosebefehl 'ninlight-station display off' erhalten. Enthaelt zusaetzlich die schon im Arbeitsbaum liegende PHASE_LABELS-Korrektur in render.py (HORCHT -> LISTEN).
525 lines
28 KiB
Python
525 lines
28 KiB
Python
"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station.
|
|
|
|
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.
|
|
|
|
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
|
|
seit Pillow 10.1 eine proportionale TrueType-Schrift, keine feste Bitmapschrift - eine
|
|
Kuerzung nach Zeichenzahl (fruehere Fassung: MAX_COLS) ist dafuer keine gueltige
|
|
Breitengrenze: "W"*40 misst bei dieser Schrift fast doppelt so breit wie "i"*40, und
|
|
ausgerechnet grossbuchstabenlastige Werte - Produktnamen aus der Lampenerkennung wie
|
|
"KLYQA GU10 RGB CW/WW" - waeren trotz weniger Zeichen als die alte Grenze ueber die
|
|
Panelbreite hinausgelaufen (Fixrunde 2, MAX_COLS-Kleinigkeit war in Fixrunde 1 nicht
|
|
wirklich behoben). _truncate_to_width() selbst importiert kein Pillow (sie nimmt eine
|
|
Messfunktion entgegen, die der Aufrufer liefert) und bleibt damit wie lines() ohne Pillow
|
|
testbar; nur compose() verdrahtet sie mit einer echten PIL-Textmessung.
|
|
|
|
Der Zustand, den lines()/compose() bekommen, ist bereits zusammengefuehrt: status.json
|
|
(vom Server geschrieben) traegt kein "ssid"-Feld - die SSID ist Konfiguration aus
|
|
/etc/ninlight-station.conf. display.py liest beides und reicht ein gemeinsames dict
|
|
herein. Jedes Feld wird ueber .get() mit Vorgabewert gelesen: status.json,
|
|
control.json und die Konfigurationsdatei koennen jederzeit fehlen, halb geschrieben oder
|
|
aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
|
|
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Callable
|
|
|
|
PANEL_WIDTH_PX = 250
|
|
PANEL_HEIGHT_PX = 122
|
|
TEXT_MARGIN_PX = 2
|
|
|
|
# --- 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.
|
|
# header_phase - die Phase (AUTO/LISTEN/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). NUR das kurze Label -
|
|
# der Zielname der Freigabe steht als eigene Beiwerkzeile, siehe
|
|
# ARMED_DETAIL_PREFIX.
|
|
# 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 + 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).
|
|
#
|
|
# Dritte Hardware-Abnahme, Befund M2: der Reviewer hat zu Recht angemerkt, dass dieser
|
|
# Zweig mit den heutigen Werten nicht feuern KANN - der Modus-Text im Migrationsmodus ist
|
|
# die Konstante "MIGRATION" (MODE_LABELS), available_px ist damit fest (112 px DejaVu,
|
|
# 127 px Arial) und nie < 24. Der Zweig bleibt trotzdem, aber er ist jetzt getestet
|
|
# (test_header_phase_is_dropped_when_too_little_room_is_left): was ihn aufweckt, ist
|
|
# nicht ein langer Zielname (den gibt es seit Befund I2 im Kopf gar nicht mehr), sondern
|
|
# ein schmalerer Rest neben dem Modus - eine groessere HEADER_FONT_PX, eine deutlich
|
|
# breitere Schrift, ein kuenftiger dritter Modus mit laengerem Namen oder ein groesserer
|
|
# HEADER_PHASE_GAP_PX. Ohne den Zweig waere die Folge kein fehlendes Wort, sondern ein
|
|
# "…", das sich mit dem Modus ueberlagert; das ist der Grund, warum hier ein
|
|
# totgeglaubtes Sicherheitsnetz billiger ist als sein Fehlen.
|
|
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
|
|
# 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 (es zieht zusaetzlich eine Beiwerkzeile mit dem
|
|
# Zielnamen nach sich), deshalb hier nicht gelistet.
|
|
PHASE_LABELS = {"auto": "AUTO", "listen": "LISTEN"}
|
|
|
|
# Dritte Hardware-Abnahme, Befund I2: in der Kopfzeile stand frueher
|
|
# "FREIGEGEBEN: <zielname>" - der Platz neben dem Modus reicht dafuer nicht (mit DejaVu
|
|
# auf dem Pi bleiben 112 px, der Text misst 182 px), gezeichnet wurde also "FREIGEGEBE…":
|
|
# ein Wortfragment, das bei JEDEM Ziel gleich aussieht. Ausgerechnet in "armed" muss der
|
|
# Bediener aber sehen, WELCHES Ziel freigegeben ist, sonst gibt er im Zweifel die falsche
|
|
# Lampe frei. Deshalb im Kopf nur noch das kurze Label (das auch bei einer deutlich
|
|
# breiteren Schrift hoechstens am Ende beschnitten wird, ohne dass dabei eine Auskunft
|
|
# verloren geht - der Zustand "freigegeben" ist der einzige mit diesem Label) und der
|
|
# Zielname als eigene Beiwerkzeile ueber die volle Panelbreite.
|
|
ARMED_LABEL = "FREIGEGEBEN"
|
|
ARMED_DETAIL_PREFIX = "Freigabe: "
|
|
|
|
|
|
def _hhmm(ts: object) -> str:
|
|
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
|
|
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
|
|
if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T":
|
|
return ts[11:16]
|
|
return "--:--"
|
|
|
|
|
|
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[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). 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", ROLE_BIG))
|
|
return out
|
|
|
|
out.append((MODE_LABELS[mode], ROLE_HEADER))
|
|
|
|
ssid = state.get("ssid")
|
|
|
|
if mode == "recovery":
|
|
# 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:
|
|
# 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.
|
|
#
|
|
# 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")
|
|
armed_target = None
|
|
if phase == "armed":
|
|
# Nur das Label in den Kopf, der Zielname kommt weiter unten als eigene
|
|
# Beiwerkzeile (siehe ARMED_DETAIL_PREFIX) - in den Kopf passt er nicht,
|
|
# und dort war er bis zur dritten Abnahme faktisch unsichtbar.
|
|
out.append((ARMED_LABEL, ROLE_HEADER_PHASE))
|
|
armed_target = str(state.get("armed_target") or "?")
|
|
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 = {}
|
|
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))
|
|
|
|
if armed_target is not None:
|
|
# ERSTE Beiwerkzeile, also die, die als letzte der unteren Randgrenze zum
|
|
# Opfer fiele (compose() bricht von unten ab, blocks() ist nach Wichtigkeit
|
|
# sortiert): in "armed" ist das freigegebene Ziel die Auskunft, wegen der
|
|
# der Bediener ueberhaupt aufs Panel sieht.
|
|
out.append((f"{ARMED_DETAIL_PREFIX}{armed_target}", ROLE_DETAIL))
|
|
|
|
if product_name:
|
|
target = last_seen.get("target")
|
|
variant = last_seen.get("variant")
|
|
if target or variant:
|
|
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}", ROLE_DETAIL))
|
|
if ssid and armed_target is None:
|
|
# Nur hier, wo die grosse Zeile schon vergeben ist - sonst stuende die
|
|
# SSID zweimal auf dem Panel.
|
|
#
|
|
# Und nur ohne Freigabezeile: aufs Panel passen neben der grossen Zeile
|
|
# fuenf Beiwerkzeilen (siehe BOTTOM_MARGIN_PX), die Freigabezeile ist die
|
|
# sechste. Von den beiden gibt die SSID nach - sie ist die Auskunft, mit
|
|
# der man das Netz FINDET, und in "armed" hat die Lampe es laengst
|
|
# gefunden (sie wurde ja erkannt, sonst gaebe es kein product_name);
|
|
# ohne erkannte Lampe steht die SSID ohnehin als grosse Zeile da.
|
|
out.append((f"SSID: {ssid}", ROLE_DETAIL))
|
|
|
|
# Der optionale Zeitschalter, in BEIDEN Modi und vor den reinen Zaehlerzeilen: ein
|
|
# Zeitschalter, den man am Panel nicht sieht, ist schlimmer als keiner - man glaubt,
|
|
# die Station laeuft, und sie ist laengst aus. Fehlt er (der Regelfall, denn er
|
|
# entsteht nur mit "--for"), fehlt die Zeile ersatzlos.
|
|
#
|
|
# Er steht damit VOR "Ausgeliefert"/"zuletzt ausgeliefert": im vollsten Zustand
|
|
# passen fuenf Beiwerkzeilen aufs Panel (siehe BOTTOM_MARGIN_PX), die sechste faellt
|
|
# unten heraus. Von diesen beiden Auskuenften ist "wann geht die Station aus"
|
|
# diejenige, nach der man ueberhaupt aufs Panel sieht - der Zaehler der letzten
|
|
# Auslieferung steht auch noch in "ninlight-station status".
|
|
autooff_minutes = state.get("autooff_minutes")
|
|
if isinstance(autooff_minutes, int) and not isinstance(autooff_minutes, bool):
|
|
if autooff_minutes > 0:
|
|
out.append(("Auto-Aus in {} min".format(autooff_minutes), ROLE_DETAIL))
|
|
else:
|
|
out.append(("Auto-Aus steht an", ROLE_DETAIL))
|
|
|
|
served = state.get("served")
|
|
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 '?'}", 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.
|
|
|
|
Nimmt eine Messfunktion statt selbst Pillow zu importieren: so bleibt diese Funktion
|
|
(und damit render.py ausserhalb von compose()) ohne Pillow testbar, z.B. mit
|
|
`measure=len` fuer einen einfachen Zeichenzahl-Test, waehrend compose() unten eine
|
|
echte `ImageDraw.textlength()`-Messung durchreicht. Zeichenweise gekuerzt (nicht
|
|
wortweise): lines() liefert kurze Statuswerte, keinen Fliesstext - ein am Wortende
|
|
abgeschnittener Produktname ist hier kein Problem, ein zu breiter oder umgebrochener
|
|
waere eins."""
|
|
if measure(text) <= max_width_px:
|
|
return text
|
|
ellipsis = "…"
|
|
trimmed = text
|
|
while trimmed and measure(trimmed + ellipsis) > max_width_px:
|
|
trimmed = trimmed[:-1]
|
|
return (trimmed + ellipsis) if trimmed else ellipsis
|
|
|
|
|
|
# Prozess-Cache fuer _load_font(). Dritte Hardware-Abnahme, Befund M4: compose() baute je
|
|
# Aufruf FUENF Schriften neu (auf der Entwicklungsmaschine gemessen: 25,0 ms je compose(),
|
|
# davon 21,2 ms Schriftaufrufe - auf einem ARMv6-Kern ohne NEON grob 0,5-1 s, und das auf
|
|
# demselben Kern, auf dem server.py die Lampen bedient, bei JEDER ausgelieferten Lampe,
|
|
# weil sich der Zaehler aendert).
|
|
#
|
|
# Der Schluessel enthaelt das hereingereichte ImageFont-MODUL und FONT_SEARCH_PATHS, nicht
|
|
# nur Groesse/Fettung: beide werden in den Tests ersetzt (nachgebautes Modul,
|
|
# monkeypatch auf eine leere oder andere Suchkette), und ein Cache, der das ignoriert,
|
|
# wuerde einem Test die Schrift eines anderen unterschieben - genau der Grund, aus dem
|
|
# Task 7b noch bewusst auf einen Cache verzichtet hatte.
|
|
_FONT_CACHE: dict = {}
|
|
|
|
|
|
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. Das Ergebnis wird prozessweit
|
|
zwischengespeichert (siehe _FONT_CACHE): dieselbe Schrift in derselben Groesse wird
|
|
je Prozess genau einmal von der Platte gelesen und geparst.
|
|
|
|
`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."""
|
|
# Das Modul selbst (nicht seine id()) im Schluessel: Module und die nachgebauten
|
|
# Testmodule sind ueber ihre Identitaet hashbar, und solange der Schluessel das
|
|
# Objekt festhaelt, kann keine recycelte id() zwei verschiedene Module
|
|
# zusammenwerfen.
|
|
key = (image_font, size_px, bold, FONT_SEARCH_PATHS)
|
|
if key in _FONT_CACHE:
|
|
return _FONT_CACHE[key]
|
|
|
|
for regular_path, bold_path in FONT_SEARCH_PATHS:
|
|
path = bold_path if bold else regular_path
|
|
try:
|
|
font = image_font.truetype(path, size_px)
|
|
break
|
|
except (OSError, ImportError):
|
|
continue
|
|
else:
|
|
font = image_font.load_default()
|
|
|
|
_FONT_CACHE[key] = font
|
|
return font
|
|
|
|
|
|
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)
|
|
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((visual_x - bbox[0], y), text, font=font, fill=fill)
|
|
return ink_width
|
|
|
|
|
|
def compose(state: dict):
|
|
"""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)
|
|
|
|
# Je Rolle FAUL geladen, nicht alle fuenf im Voraus (Befund M4): "Station aus" und
|
|
# "Status unbekannt" zeichnen genau eine Rolle (ROLE_BIG) - vier der fuenf Schriften
|
|
# waeren dort reine Ladezeit. Zusammen mit _FONT_CACHE kostet jede Rolle im Betrieb
|
|
# hoechstens einmal je Prozess etwas, danach nur noch einen Woerterbuchzugriff.
|
|
role_fonts = {
|
|
ROLE_HEADER: (HEADER_FONT_PX, True),
|
|
ROLE_HEADER_PHASE: (HEADER_PHASE_FONT_PX, True),
|
|
ROLE_HERO: (HERO_FONT_PX, True),
|
|
ROLE_DETAIL: (DETAIL_FONT_PX, False),
|
|
ROLE_BIG: (BIG_FONT_PX, True),
|
|
}
|
|
loaded: dict = {}
|
|
|
|
def font_for(role: str):
|
|
if role not in loaded:
|
|
size_px, bold = role_fonts[role]
|
|
loaded[role] = _load_font(ImageFont, size_px, bold=bold)
|
|
return loaded[role]
|
|
|
|
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
|
|
|
|
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,
|
|
# 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 blocks() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
|
|
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
|
|
text = raw_text.replace("\n", " ")
|
|
return _truncate_to_width(
|
|
text, measure=lambda t: draw.textlength(t, font=font),
|
|
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 block_list:
|
|
font = font_for(role)
|
|
|
|
if role == ROLE_BIG:
|
|
# Einziger Block dieses Zustands (siehe blocks()) - er bekommt das ganze
|
|
# Panel.
|
|
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)
|
|
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 - 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)
|
|
y += line_height
|
|
|
|
return image
|