"""Textzerlegung und Bildaufbau fuer die E-Paper-Anzeige der ninlight-Station. lines() ist 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 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). 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 MAX_COLS = 40 MODE_LABELS = {"recovery": "RETTUNG", "migration": "MIGRATION"} # "armed" wird gesondert behandelt (braucht armed_target im Text), deshalb hier nicht # gelistet. 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) -> "--:--".""" if isinstance(ts, str) and len(ts) >= 16 and ts[10:11] == "T": return ts[11:16] return "--:--" def lines(state: dict) -> list[str]: """Zerlegt den zusammengefuehrten Zustand in Textzeilen fuers Panel. Wirft nie - fehlende/falsch geformte Felder werden ausgelassen statt eine Ausnahme zu werfen.""" state = state if isinstance(state, dict) else {} mode = state.get("mode") out: list[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") 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(_cut(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}")) if mode == "migration": # 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(_cut(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") if product_name: out.append(_cut(product_name)) target = last_seen.get("target") variant = last_seen.get("variant") if target or variant: out.append(_cut(f"{target or '?'} ({variant or '?'})")) lamp_version = last_seen.get("lamp_version") if lamp_version: out.append(_cut(f"FW: {lamp_version}")) served = state.get("served") out.append(_cut(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 '?'}")) return out def compose(state: dict): """Baut das Panel-Bild (250x122, 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) draw = ImageDraw.Draw(image) font = ImageFont.load_default() line_height = 14 y = 2 for line in lines(state): if y > height - line_height: break draw.text((2, y), line, font=font, fill=0) y += line_height return image