Ein Taster auf GPIO23 (active low, 50 ms entprellt) bedient die Station am Geraet: kurz oeffnet das Menue bzw. blaettert, gehalten waehlt (1,5 s), bricht ab (3 s, feuert sofort) und faehrt herunter (10 s, nur ueber die sichtbare Abbruchstufe hinweg). Menue schliesst nach 20 s, Rueckfrage verfaellt nach 10 s als Abbruch; jede veraendernde Auswahl bekommt eine Rueckfrage. Noetig, weil "on"/"migrate on" wlan0 zum Zugangspunkt macht und damit jede SSH-Sitzung abreisst - bisher blieb dort nur warten oder Stecker ziehen. "Migration starten" startet ueber das Menue IMMER mit --listen: der Migrations-AP hat ein oeffentlich bekanntes Passwort, ein Tastendruck im Automatikmodus wuerde jede alte Klyqa-Lampe in Reichweite ungefragt migrieren. Der Tasterdienst fasst den SPI-Bus nicht an (sonst "GPIO busy"), sondern schreibt seinen Bedienzustand atomar mit 0644 nach /run/ninlight-button/ui.json - bewusst nicht ins RuntimeDirectory der Hauptunit, das systemd beim "off" aufraeumt. Der Anzeigedienst liest die Datei jede halbe Sekunde (der teure build_state()-Fork bleibt beim 5-Sekunden-Takt) und zeichnet sie als Teilaufbau, kostet dem Panel also keinen Vollaufbau je Tastendruck. Fehlt gpiozero oder der Taster, gibt es genau eine Protokollzeile und sys.exit(0) - dieselbe Zusage wie bei der Anzeige. Ausliefern/Einrichten folgt in einem eigenen Durchgang.
565 lines
29 KiB
Python
565 lines
29 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 {}
|
|
|
|
# Laeuft gerade eine Bedienung am Taster, ERSETZT sie das Statusbild komplett - eine
|
|
# Bedienung, die neben dem Statusbild um Platz kaempft, waere auf 250x122 unlesbar,
|
|
# und in diesem Moment ist der Bedienzustand die einzige Auskunft, auf die der
|
|
# Bediener wartet. display.read_ui() liefert {} (und damit kein "ui"), sobald nichts
|
|
# laeuft - dann geht es unveraendert weiter wie zuvor.
|
|
ui = state.get("ui")
|
|
if isinstance(ui, dict) and ui.get("view") not in (None, "", "idle"):
|
|
return _ui_blocks(ui)
|
|
|
|
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 _ui_blocks(ui: dict) -> list[tuple[str, str]]:
|
|
"""Der Bedienzustand des Tasterdienstes in Zeilen mit Rolle. Bewusst DUMM: welcher
|
|
Text bei welcher Geste erscheint, entscheidet allein der Tasterdienst (dort ist es
|
|
ohne Hardware pruefbar) - hier wird er nur noch den vorhandenen Rollen zugeordnet,
|
|
damit Kopfbalken, Schriftgroessen, Breitenkuerzung und unterer Rand dieselben bleiben
|
|
wie im Statusbild.
|
|
|
|
Jedes Feld wird auf seinen Typ geprueft: die Datei kommt aus einem anderen Prozess
|
|
(der aelter, neuer oder halb geschrieben sein kann) - genau wie status.json darf sie
|
|
die Anzeige nie zum Absturz bringen."""
|
|
out: list[tuple[str, str]] = []
|
|
title = ui.get("title")
|
|
if isinstance(title, str) and title:
|
|
out.append((title, ROLE_HEADER))
|
|
hero = ui.get("hero")
|
|
if isinstance(hero, str) and hero:
|
|
out.append((hero, ROLE_HERO))
|
|
details = ui.get("details")
|
|
if isinstance(details, list):
|
|
for detail in details:
|
|
if isinstance(detail, str) and detail:
|
|
out.append((detail, ROLE_DETAIL))
|
|
if not out:
|
|
# Es laeuft eine Bedienung, aber die Datei traegt keinen brauchbaren Text
|
|
# (abgeschnitten, fremde Fassung). Lieber gross sagen, dass bedient wird, als
|
|
# ein leeres Panel zu zeigen, das nach "haengt" aussieht.
|
|
out.append(("Bedienung", ROLE_BIG))
|
|
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
|