render.lines() zerlegt den zusammengefuehrten Zustand (status.json + control.json + SSID aus der Konfigurationsdatei) in Textzeilen, ohne Pillow und ohne Hardware zu brauchen - render.compose() importiert Pillow erst innerhalb der Funktion. epdconfig.py ist eine eigene GPIO/SPI-Schicht auf gpiozero/spidev, weil RPi.GPIO auf Debian 13 mit Python 3.13 nicht mehr traegt; epd2in13_V2.py/epd2in13_V3.py sind unveraendert aus dem Waveshare-Repository uebernommen (siehe HERKUNFT.md), nur der epdconfig-Import zeigt auf unser Modul. display.py laeuft als eigener systemd-Dienst (Wants=, Restart=on-failure), fehlendes Panel/SPI/Bibliothek beendet ihn sauber mit sys.exit(0) statt einer Neustartschleife - eine Migration darf davon nie abhaengen. Verdrahtung in setup_pi.sh/deploy.sh/ninlight-station folgt in einem eigenen Durchgang.
134 lines
5.3 KiB
Python
134 lines
5.3 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 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 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 = 42
|
|
|
|
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
|