Fixrunde 1 zu Task 6a: epd2in13_V2.init() braucht ein Pflichtargument und dessen displayPartial() setzt selbst keine LUT (neue Panel-Klasse in display.py kapselt das statt es zu verstreuen); epdconfig.module_init() war nicht wiedereintrittsfaehig und ein zu breites except tarnte GPIOPinInUse als "Panel fehlt" (jetzt idempotent, gezielter except); ninlight-display.service hatte ReadOnlyPaths=/run/ninlight-station (verhindert den Start, wenn die Station regulaer aus ist), StartLimit* in der falschen Sektion und SupplementaryGroups=spi gpio (harter Fehler auf reinem Debian ohne diese Gruppen); ein fehlendes Pillow fuehrte zu ModuleNotFoundError statt zum vorgesehenen stillen Exit 0. Dazu: Panel schlaeft nach jedem Zeichnen (Alterung), Modus-Vorrang liegt jetzt bei RUN_DIR/mode statt bei status.json, export-Praefix in der Konfigurationsdatei wird erkannt, MAX_COLS auf das mit Pillow nachgemessene Mass korrigiert, und build_state() hat jetzt eigene Tests.
137 lines
5.6 KiB
Python
137 lines
5.6 KiB
Python
"""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
|