Fixrunde 3 zu Task 6a. EnvironmentFile=-/etc/ninlight-station.conf statt -EnvironmentFile= (Fixrunde 2 hatte das Praefix vor den Direktivennamen statt vor den Dateipfad gesetzt - systemd verwarf die Zeile komplett, die Konfigurationsdatei wurde ueberhaupt nicht mehr gelesen). clear() stellte die in Panel dokumentierte Awake-Invariante nicht her (kein 0x26-Referenzpuffer) und verwendet jetzt wie draw_full() displayPartBaseImage(). Die 30-Minuten- Vollaufbau-Uhr feuerte bisher auch ohne vorherige Teilaufbauten und erzeugte im eingeschwungenen Ruhezustand einen endlosen Schlaf-/Wach-Zyklus mit 33 Prozent Bias-Anteil - sie greift jetzt nur noch, wenn seit dem letzten Vollaufbau tatsaechlich Teilaufbauten stattfanden, und der Zaehler wird beim Einschlafen zurueckgesetzt. draw_partial() meldet zurueck, ob es intern zu einem Vollaufbau aufgewertet hat, damit die 30-Minuten-Uhr korrekt bleibt. compose() ersetzt eingebettete Zeilenumbrueche vor der Breitenmessung. Dazu ein Kommando-Rekorder fuer die Tests, der die tatsaechlich gesendete SPI-Byte-Folge protokolliert: die vorherigen Tests fuer den Referenzpuffer (N2) und die Registerfolge nach dem Schlafen (N3) pruefften nur "keine Ausnahme" und blieben laut Reviewer-Mutation gruen, wenn die eigentliche Fehlerursache wieder eingefuehrt wurde. Die neuen Tests pruefen die gesendeten Kommandos direkt und wurden gegen genau diese Mutationen verifiziert.
170 lines
7.8 KiB
Python
170 lines
7.8 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 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
|
|
/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
|
|
|
|
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
|
|
# gelistet.
|
|
PHASE_LABELS = {"auto": "AUTO", "listen": "HORCHT"}
|
|
|
|
|
|
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 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")
|
|
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(f"Status unbekannt ({mode})" if mode else "Status unbekannt")
|
|
return out
|
|
|
|
out.append(MODE_LABELS[mode])
|
|
|
|
ssid = state.get("ssid")
|
|
if ssid:
|
|
out.append(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(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(str(product_name))
|
|
target = last_seen.get("target")
|
|
variant = last_seen.get("variant")
|
|
if target or variant:
|
|
out.append(f"{target or '?'} ({variant or '?'})")
|
|
lamp_version = last_seen.get("lamp_version")
|
|
if lamp_version:
|
|
out.append(f"FW: {lamp_version}")
|
|
|
|
served = state.get("served")
|
|
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(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 (PANEL_WIDTH_PX x PANEL_HEIGHT_PX, 1-Bit) aus lines().
|
|
Importiert Pillow erst hier - siehe Modul-Docstring."""
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
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
|
|
|
|
def measure(text: str) -> float:
|
|
return draw.textlength(text, font=font)
|
|
|
|
y = TEXT_MARGIN_PX
|
|
for raw_line in lines(state):
|
|
if y > PANEL_HEIGHT_PX - LINE_HEIGHT_PX:
|
|
break
|
|
# draw.textlength() wirft ValueError("can't measure length of multiline text"),
|
|
# sobald eine Zeile ein "\n" enthaelt (mit Pillow 12 nachgeprueft) - waere von
|
|
# main()s except (DisplayUnavailable, ImportError) nicht gefangen (Fixrunde 3,
|
|
# F5). lines() erzeugt selbst kein "\n", aber Werte aus status.json/last_seen
|
|
# (z.B. product_name) sind letztlich Daten von der Lampe/dem Netzwerk, kein
|
|
# Wert, den lines() selbst kontrolliert - ein Zeilenumbruch dort waere ohnehin
|
|
# nur ein zerstoertes Layout, kein sinnvoller Inhalt.
|
|
line = raw_line.replace("\n", " ")
|
|
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
|