feat(station): E-Paper-Anzeige fuer Modus, Lampe und Auslieferungen
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.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user