fix(station): Panel schlaeft nicht mehr nach jedem Bild, Breitenkuerzung nach Pixeln
Fixrunde 2 zu Task 6a: der sleep()-Einbau aus Fixrunde 1 hatte drei neue Critical erzeugt. epd.sleep() schliesst ueber epdconfig.module_exit() die GPIO-Pins; ein Teilaufbau direkt danach fasste bei V3 eine bereits geschlossene Pin-Referenz an (AttributeError, ungefangen, Neustartschleife - binnen Sekunden nach dem Start, V3 ist die Vorgaberevision) und war bei V2 nach Deep Sleep ohnehin keine belastbare Sequenz (init(PART_UPDATE) allein setzt nicht die Register, die nur der FULL_UPDATE-Zweig setzt). Ausserdem setzte der V3-Vollaufbau nie den Referenzpuffer, den ein Teilaufbau zum Diffen braucht (displayPartBaseImage() statt display()). Panel bekommt ein internes _awake-Flag: draw_partial() wertet sich selbst zu einem Vollaufbau auf, wenn das Panel nicht wach ist (frisch erzeugt oder seit dem letzten Aufbau geschlafen), unabhaengig von der Aufrufreihenfolge. sleep() wird nur noch nach laengerer Ruhe (10 Minuten ohne Aenderung) gerufen, nicht mehr nach jedem Bild, und ist selbst idempotent. Dazu: epdconfig faengt jetzt gpiozero.GPIOZeroError statt nur GPIODeviceError (die PinError-Familie haengt nicht darunter), EnvironmentFile= in der Unit bekommt ein "-"-Praefix, und die Zeilenkuerzung wandert von einer Zeichenzahl-Grenze (ungueltig bei einer Proportionalschrift) zu einer echten Pixelbreitenmessung in compose() - lines() bleibt dafuer ungekuerzt und weiterhin ohne Pillow testbar.
This commit is contained in:
@@ -5,13 +5,19 @@ tools/station/tests/test_render.py) - compose() importiert Pillow erst innerhalb
|
||||
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 250x122 Pixel, quer verwendet. MAX_COLS begrenzt jede Textzeile so, dass sie
|
||||
bei der in compose() verwendeten Schriftgroesse (PIL.ImageFont.load_default(), Textursprung
|
||||
x=2) nicht umgebrochen werden muss - ueberlange Werte (z.B. ein Produktname aus der
|
||||
Lampenerkennung) werden mit "..." gekuerzt, nie umgebrochen (Spec: eine zu lange Zeile darf
|
||||
das Layout nicht sprengen). Der Wert 40 ist mit genau dieser Schrift nachgemessen
|
||||
(draw.textbbox(): 40 Zeichen reichen bis x=243, 42 bis x=255 - beim 250 px breiten Panel
|
||||
waere das "..." selbst abgeschnitten gewesen, siehe Fixrunde 1, Kleinigkeit).
|
||||
Das Panel ist PANEL_WIDTH_PX x PANEL_HEIGHT_PX (250x122), quer verwendet. lines() 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
|
||||
@@ -22,7 +28,12 @@ aus einer aelteren Fassung sein (frisch aufgesetzter Pi, Schreibfehler mitten im
|
||||
Schreiben, Neustart) - siehe test_missing_fields_do_not_raise."""
|
||||
from __future__ import annotations
|
||||
|
||||
MAX_COLS = 40
|
||||
from typing import Callable
|
||||
|
||||
PANEL_WIDTH_PX = 250
|
||||
PANEL_HEIGHT_PX = 122
|
||||
TEXT_MARGIN_PX = 2
|
||||
LINE_HEIGHT_PX = 14
|
||||
|
||||
MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
||||
# "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht
|
||||
@@ -30,16 +41,6 @@ MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"}
|
||||
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
|
||||
|
||||
|
||||
def _cut(text: object) -> str:
|
||||
"""Kuerzt auf MAX_COLS Zeichen, mit "..." am Ende statt Umbruch."""
|
||||
text = str(text)
|
||||
if len(text) <= MAX_COLS:
|
||||
return text
|
||||
if MAX_COLS <= 1:
|
||||
return text[:MAX_COLS]
|
||||
return text[: MAX_COLS - 1] + "…"
|
||||
|
||||
|
||||
def _hhmm(ts: object) -> str:
|
||||
""""2026-09-13T13:04:02Z" -> "13:04". Alles andere (fehlt, falsch geformt, aus einer
|
||||
aelteren Serverfassung ohne Zeitstempel) -> "--:--"."""
|
||||
@@ -49,7 +50,8 @@ def _hhmm(ts: object) -> str:
|
||||
|
||||
|
||||
def lines(state: dict) -> list[str]:
|
||||
"""Zerlegt den zusammengefuehrten Zustand in Textzeilen fuers Panel. Wirft nie -
|
||||
"""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."""
|
||||
state = state if isinstance(state, dict) else {}
|
||||
mode = state.get("mode")
|
||||
@@ -67,14 +69,14 @@ def lines(state: dict) -> list[str]:
|
||||
# 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(_cut(f"Status unbekannt ({mode})") if mode else "Status unbekannt")
|
||||
out.append(f"Status unbekannt ({mode})" if mode else "Status unbekannt")
|
||||
return out
|
||||
|
||||
out.append(MODE_LABELS[mode])
|
||||
|
||||
ssid = state.get("ssid")
|
||||
if ssid:
|
||||
out.append(_cut(f"SSID: {ssid}"))
|
||||
out.append(f"SSID: {ssid}")
|
||||
|
||||
if mode == "migration":
|
||||
# Phase kommt aus control.json, nicht aus status.json (das schreibt der Server
|
||||
@@ -84,7 +86,7 @@ def lines(state: dict) -> list[str]:
|
||||
phase = state.get("phase")
|
||||
if phase == "armed":
|
||||
target = state.get("armed_target") or "?"
|
||||
out.append(_cut(f"FREIGEGEBEN: {target}"))
|
||||
out.append(f"FREIGEGEBEN: {target}")
|
||||
elif phase in PHASE_LABELS:
|
||||
out.append(PHASE_LABELS[phase])
|
||||
|
||||
@@ -93,44 +95,67 @@ def lines(state: dict) -> list[str]:
|
||||
last_seen = {}
|
||||
product_name = last_seen.get("product_name")
|
||||
if product_name:
|
||||
out.append(_cut(product_name))
|
||||
out.append(str(product_name))
|
||||
target = last_seen.get("target")
|
||||
variant = last_seen.get("variant")
|
||||
if target or variant:
|
||||
out.append(_cut(f"{target or '?'} ({variant or '?'})"))
|
||||
out.append(f"{target or '?'} ({variant or '?'})")
|
||||
lamp_version = last_seen.get("lamp_version")
|
||||
if lamp_version:
|
||||
out.append(_cut(f"FW: {lamp_version}"))
|
||||
out.append(f"FW: {lamp_version}")
|
||||
|
||||
served = state.get("served")
|
||||
out.append(_cut(f"Ausgeliefert: {served if isinstance(served, int) else 0}"))
|
||||
out.append(f"Ausgeliefert: {served if isinstance(served, int) else 0}")
|
||||
|
||||
recent = state.get("recent")
|
||||
if isinstance(recent, list) and recent:
|
||||
last = recent[-1]
|
||||
if isinstance(last, dict):
|
||||
out.append(_cut(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 '?'}")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def compose(state: dict):
|
||||
"""Baut das Panel-Bild (250x122, 1-Bit) aus lines(). Importiert Pillow erst hier -
|
||||
siehe Modul-Docstring."""
|
||||
"""Baut das Panel-Bild (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus lines().
|
||||
Importiert Pillow erst hier - siehe Modul-Docstring."""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
width, height = 250, 122
|
||||
image = Image.new("1", (width, height), 255)
|
||||
image = Image.new("1", (PANEL_WIDTH_PX, PANEL_HEIGHT_PX), 255)
|
||||
draw = ImageDraw.Draw(image)
|
||||
font = ImageFont.load_default()
|
||||
max_width_px = PANEL_WIDTH_PX - 2 * TEXT_MARGIN_PX
|
||||
|
||||
line_height = 14
|
||||
y = 2
|
||||
def measure(text: str) -> float:
|
||||
return draw.textlength(text, font=font)
|
||||
|
||||
y = TEXT_MARGIN_PX
|
||||
for line in lines(state):
|
||||
if y > height - line_height:
|
||||
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
|
||||
break
|
||||
draw.text((2, y), line, font=font, fill=0)
|
||||
y += line_height
|
||||
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
|
||||
|
||||
return image
|
||||
|
||||
Reference in New Issue
Block a user