Am echten Pi stand im Aus-Zustand "LAN: not connected", obwohl er unter
192.168.2.96 erreichbar war - derselbe build_state()-Aufruf von Hand lieferte die
Adresse dagegen richtig. Ursache ist die Absicherung des Anzeigedienstes:
ninlight-display.service setzt RestrictAddressFamilies=AF_UNIX AF_NETLINK, der
UDP-Kniff in local_ip() legt aber ein AF_INET-Socket an. Das scheitert dort mit
EAFNOSUPPORT, und die Zeile fiel auf "not connected" zurueck. Kein Test hat das
gefunden, weil alle ohne diese Beschraenkung liefen.
Statt die Absicherung zu lockern ermittelt local_address() die Adresse jetzt mit
"ip -4 route get" - das spricht Netlink und bleibt damit innerhalb der Erlaubnis.
Der Unterprozess faellt nur im Aus-Zustand an, dem einzigen Statusbild mit dieser
Zeile.
Dazu die Beschriftung: "LAN" war eine Falschauskunft, dieser Pi haengt per WLAN
im Netz und hat gar kein Kabel. Der Kernel nennt in derselben Auskunft beides,
also steht dort jetzt "wlan0: 192.168.2.96" (bzw. "eth0: ..." am Kabel) und ohne
Netz "no home network" statt eines nackten "no network", das schon einmal auf den
Rettungs-Zugangspunkt bezogen wurde.
Der neue Test sperrt AF_INET wie die Unit und verlangt die Adresse trotzdem -
genau die Luecke, die den Befund ueberhaupt durchgelassen hat.
Fixrunde 1, Befund 3: RUN_DIR/mode ueberlebt nur einen "systemctl restart"
(RuntimeDirectoryPreserve=restart) - "ninlight-station off" fuehrt aber einen
echten "systemctl stop" aus, der das RuntimeDirectory leert. build_state()
fiel danach unbemerkt auf "recovery" zurueck, auch wenn tatsaechlich eine
Migration anstand - die Fusszeile haette dann "in Ordnung" gezeigt, obwohl der
Migrationssatz leer ist. Ein Docstring behauptete ausdruecklich das Gegenteil.
_read_mode_file() liest jetzt eine neue, dauerhafte LAST_MODE_FILE (unter
/var/lib/ninlight-station, wie der Bildsatz-Umschalter selbst) statt
RUN_DIR/mode; write_mode() im CLI schreibt beide. Ausserdem: _read_imageset()
faengt jetzt auch UnicodeDecodeError ab (Fixrunde 1, Kleinigkeit).
Ein neuer Test bildet den echten Ablauf nach (Modus schreiben, RUN_DIR wie bei
einem echten Stop leeren, pruefen dass der Modus trotzdem stimmt) - der alte
Test fuer den Rettungs-Bildsatz liess die Modusdatei ganz weg und pruefte
damit nur den zufaellig gleichen Rueckfallwert, nicht die Wirklichkeit.
display.py/_read_imageset() prueft jetzt das zum Modus passende Verzeichnispaar
(rescue-* im Rettungsmodus, migrate-* in der Migration) - vorher pruefte die
Fusszeile immer nur den Migrationssatz, auch im Rettungsmodus.
render.py haengt einen kleinen Punkt an die Servierzeile, solange
display.py:_recent_request() eine Anfrage der letzten ACTIVITY_WINDOW_S
Sekunden meldet - faehrt ueber den vorhandenen Textvergleich mit, kein
zusaetzlicher Zeichentakt fuer das E-Paper.
Aus-Zustand: "no network" hiess fuer einen Bediener wie "der Migrations-/
Rettungs-Zugangspunkt ist aus" - gemeint ist das Gegenteil, das Heimnetz.
Jetzt "LAN: <ip>" bzw. "LAN: not connected".
run_daemon() faengt jetzt jeden Fehler beim Erheben des Zustands ab
(_safe_read_state) statt die Schleife zu beenden - sonst friert das Panel
nach einem einzigen unerwarteten Lesefehler fuer immer auf dem letzten Bild
ein, ohne dass das am Geraet zu erkennen waere.
Die deutschen Texte sahen auf dem 2,13"-Panel merkwuerdig aus - jetzt sind
alle Texte, die der Bediener am Geraet oder auf der Konsole sieht, englisch:
Modus-, Phasen-, Bildsatz- und Abschlussbeschriftungen in render.py, die
Bedientexte des Tasterdienstes und saemtliche Ausgaben von ninlight-station
inklusive des Hilfetexts. Die Testbeschreibungen der Suiten bleiben deutsch -
die liest nur, wer die Tests laufen laesst.
Neu im Off-Bild: die Adresse, unter der der Pi gerade erreichbar ist. Ist die
Station aus, ist der Pi ein gewoehnlicher Rechner im Heimnetz, und die
einzige Auskunft, die man dann am Panel braucht, ist die fuer SSH. Die
Adresse kommt aus einem UDP-Socket, der nur verbunden und nie benutzt wird
(kein Datenverkehr, kein DNS, kein Unterprozess in einer 5-s-Schleife) und
liefert genau die Adresse der Standardroute. Ohne Netz sagt die Zeile
ausdruecklich "no network": eine fehlende Zeile wuerde offen lassen, warum
SSH gerade scheitert.
Die Zeile gibt es NUR im Off-Bild - im Betrieb traegt wlan0 die Adresse des
Zugangspunkts, die ohnehin auf jedem Statusbild steht.
Zweiter Fund derselben Art wie die Bildsatz-Fusszeile: 'Strom kann getrennt werden'
misst in der Hero-Schrift 262,1 px bei 246 px Platz und kam am Geraet als
'Strom kann getrennt wer...' an.
Der Test deckt jetzt ALLE festen Beschriftungen ab (Abschlussbilder, beide Bildsaetze,
beide Fehlt-Warnungen, Freigabe), nicht nur die zuerst entdeckte Stelle - sonst waere
genau dieser zweite Fall wieder durchgerutscht. Der Wortlauttest prueft die Zusage
('trennen erlaubt' gegen 'NICHT trennen') statt des genauen Satzes.
Am Geraet kam die Zeile gekuerzt an ('... (ohne WLA...'), und mit dem Zusatz
' - SATZ FEHLT' war die Warnung in beiden Richtungen ausserhalb der Panelbreite -
also genau die Zeile, die sagt, dass die Station nichts ausliefern kann.
Unbemerkt blieb das, weil _load_font() auf dem Pi DejaVu nimmt und auf dem Mac bis
Arial durchfaellt: dieselbe Zeile misst dort 246,0 von 246 px, auf dem Pi 283,5 px.
Der neue Test misst deshalb mit 25 % Reserve gegen eine breitere Schrift.
Die Fehlt-Warnung ersetzt die Zeile jetzt, statt sie zu verlaengern.
Die Zusage "fehlt Panel, SPI, Pillow oder Schrift: genau eine Protokollzeile
und sys.exit(0)" war ungetestet - das Abschlussreview hat sys.exit(0) durch
sys.exit(1) ersetzt und alle 225 Tests blieben gruen. Die vorhandenen Tests
pruefen nur, dass die richtigen Ausnahmetypen bis main() durchkommen, niemand
hat main() selbst in den except-Zweig getrieben.
Acht neue Faelle: DisplayUnavailable und ImportError, je fuer --test, --clear,
--final und den Daemon. Mutationsproben: exit 0 -> 1 macht alle acht rot, die
Ausnahmeliste auf DisplayUnavailable verengt macht die vier ImportError-Faelle
rot.
E-Paper behaelt sein Bild dauerhaft, auch stromlos. Faehrt der Pi
herunter, wird der Anzeigedienst mit abgeraeumt und das zuletzt
gezeichnete Bild bleibt fuer immer stehen - bei der Abnahme stand dort
die Rueckfrage des Tastermenues, und der ausgeschaltete Pi sah aus, als
warte er noch auf eine Eingabe.
Zwei neue Units, ninlight-poweroff.service (WantedBy=poweroff.target)
und ninlight-reboot.service (WantedBy=reboot.target). Sie haengen am
System, nicht am Tasterdienst: so greifen sie bei jedem Herunterfahren -
Menue, "poweroff" per SSH, "reboot" - und nicht nur auf einem von drei
Wegen. Zwei Units statt einer Fallunterscheidung, weil systemd einem
ExecStart nicht sagt, wohin die Reise geht.
Die Texte unterscheiden sich: "AUSGESCHALTET / Strom kann getrennt
werden" gegen "NEUSTART / Strom NICHT trennen!" samt Hinweis auf die
rund zwei Minuten, die der Zero W bis zum Anzeigedienst braucht. An
diesem Pi hat ein Stromzug in genau dieser Wartezeit schon eine
SD-Karte gekostet.
Reihenfolge: die Units ordnen sich per After=ninlight-display.service
hinter dessen Stop ein, und "ninlight-station display final <kind>"
wartet zusaetzlich, bis systemctl is-active nicht mehr anschlaegt -
dasselbe Muster wie display_handover(), nur ohne Wiederanfahren. Gibt
der Dienst die Leitungen nicht frei, wird nicht gezeichnet (GPIO busy),
sondern gewarnt: ein Anzeigefehler haelt das Herunterfahren nie auf.
Nebenbei behoben: display_run() reichte nur "$1" an display.py weiter
und haette "--final poweroff" halbiert.
Die WLAN-Zugangsdaten stecken zur Bauzeit in der Firmware, es gibt also
zwei Abzuege je Ziel. Die Station haelt jetzt beide vor - images/
migrate-nocreds (aus tools/build_nocreds.sh, Lampe startet im
Onboarding-AP) und images/migrate-wlan (aus build/, Lampe haengt sofort
im eigenen WLAN) - und der Bediener waehlt per Umschalter, welcher
ausgeliefert wird: "ninlight-station bildsatz" oder der Menueeintrag am
Taster.
Der Zustand liegt in /var/lib/ninlight-station/imageset, ueberlebt
Neustarts und steht dauerhaft in der Fusszeile des Panels - in jedem
Modus, auch bei ausgeschalteter Station: man soll vor dem Einschalten
sehen, was gilt.
Vorgabe und Rueckfallwert in JEDEM Zweifelsfall ist "ohne-wlan". Im
Automatikmodus wird jede Klyqa-Lampe in Reichweite migriert, auch
fremde; ein Abzug ohne Zugangsdaten ist dann ein harmloser Fehlgriff,
einer mit Zugangsdaten traegt das eigene WLAN-Passwort in ein fremdes
Geraet.
Fehlt der gewaehlte Satz (Verzeichnis weg oder leer), wird NICHTS
ausgeliefert - kein stilles Ausweichen auf den anderen Satz. Der Server
antwortet 404 mit Status "noimageset", CLI und Panel sagen "FEHLT".
server.py und die Stations-Unit muessen mit: die Auswahl der
ausgelieferten Datei ist genau die Aufgabe des Servers, und ein
Symlink-Tausch haette "gewaehlt, aber nicht vorhanden" von "dieses
Modell fehlt" nicht mehr unterscheidbar gemacht.
Eine Entwicklungs-G95 meldete sich am 2026-09-15 mit einer Produkt-ID OHNE
"-dev", aber einer Version MIT "-dev". products.py leitete die Variante
ausschliesslich aus der Produkt-ID ab, die Lampe galt als Produktionsgeraet,
bekam das produktionssigniertes Image und lehnte es ab - ihr Bootloader prueft
gegen den Entwicklungsschluessel.
Die Variante ist damit keine Eigenschaft der Produkt-ID, sondern der Anfrage.
PRODUCTS bildet nur noch auf Geraetetyp und Anzeigename ab; beide Schreibweisen
der Produkt-ID fuehren auf denselben Eintrag. classify_variant() in
parse_request() wertet Produkt-ID UND Version aus: "-dev" an einer der beiden
Stellen bedeutet Entwicklungsgeraet, in der Version nur als ganzes Wort
("-devel"/"-dev2" zaehlen nicht).
Damit eine Fehlklassifikation nicht wieder erst an der Lampe auffaellt, fuehren
status.json (last_seen und jeder recent-Eintrag) und das Panel die Quelle der
Einstufung mit: "g95_cw (dev/Ver)", "dev/ID", "dev/ID+Ver" oder "prod".
Tests: die drei am Geraet mitgeschnittenen Anfragen plus die vierte denkbare
Kombination, die Wortgrenze in der Version, zwei Ende-zu-Ende-Tests im Server
(dev-Image statt prod-Image) und die Panel-Beschriftung.
Ein Taster auf GPIO23 (active low, 50 ms entprellt) bedient die Station am
Geraet: kurz oeffnet das Menue bzw. blaettert, gehalten waehlt (1,5 s),
bricht ab (3 s, feuert sofort) und faehrt herunter (10 s, nur ueber die
sichtbare Abbruchstufe hinweg). Menue schliesst nach 20 s, Rueckfrage
verfaellt nach 10 s als Abbruch; jede veraendernde Auswahl bekommt eine
Rueckfrage.
Noetig, weil "on"/"migrate on" wlan0 zum Zugangspunkt macht und damit jede
SSH-Sitzung abreisst - bisher blieb dort nur warten oder Stecker ziehen.
"Migration starten" startet ueber das Menue IMMER mit --listen: der
Migrations-AP hat ein oeffentlich bekanntes Passwort, ein Tastendruck im
Automatikmodus wuerde jede alte Klyqa-Lampe in Reichweite ungefragt
migrieren.
Der Tasterdienst fasst den SPI-Bus nicht an (sonst "GPIO busy"), sondern
schreibt seinen Bedienzustand atomar mit 0644 nach
/run/ninlight-button/ui.json - bewusst nicht ins RuntimeDirectory der
Hauptunit, das systemd beim "off" aufraeumt. Der Anzeigedienst liest die
Datei jede halbe Sekunde (der teure build_state()-Fork bleibt beim
5-Sekunden-Takt) und zeichnet sie als Teilaufbau, kostet dem Panel also
keinen Vollaufbau je Tastendruck.
Fehlt gpiozero oder der Taster, gibt es genau eine Protokollzeile und
sys.exit(0) - dieselbe Zusage wie bei der Anzeige.
Ausliefern/Einrichten folgt in einem eigenen Durchgang.
"on"/"recovery on"/"migrate on" nehmen jetzt "--for <minuten>" (1-240): die
Station schaltet sich danach selbsttaetig ab und kehrt ins Heim-WLAN zurueck.
Das hilft, wenn man ueber WLAN angemeldet ist - "on" macht wlan0 zum
Zugangspunkt und reisst jede SSH-Sitzung ab.
Ausdruecklich optional: ohne "--for" entsteht kein Zeitschalter und alles
verhaelt sich exakt wie bisher. Dazu "renew [<minuten>]" (Uhr zurueckstellen),
"hold" (abbestellen, Station bleibt an) und eine Restzeit in "status"; "off"
raeumt einen laufenden Zeitschalter mit ab.
Umgesetzt als transienter systemd-Timer unter festem Unit-Namen (systemd-run),
Zustand in RUN_DIR/autooff.json. Vor dem Abschalten wird per "ss" geprueft, ob
noch eine Verbindung auf Port 80 aus dem Stationsnetz offen ist - dann wird um
rund eine Minute verschoben, hoechstens zehnmal (Begruendung im Kommentar).
Das Panel zeigt den Zeitschalter als eigene Zeile, aber nur wenn einer laeuft.
Sechs Review-Befunde an e2d836a/2aab1b0.
C1: die Pixelschwellen in test_render.py waren gegen Pillow 12/Arial
kalibriert und auf dem Pi wie auf Debian 13 (DejaVu) rot - "> 150
schwarze Pixel in Zeile 11" sind dort nur 143. Die Zusagen ueber den
Kopfbalken sind jetzt Anteile und Symmetrien statt Pixelzahlen
(mindestens 55 % des Balkens schwarz, weisse Schrift links UND rechts
der Mitte, Balken ueber die volle Breite). Zwei weitere schriftabhaengige
Schwellen ebenso: die grosse Zeile hebt sich ueber das VERHAELTNIS der
Tintenhoehen vom Beiwerk ab, und die Randgrenze zaehlt Zeilenbaender
statt Tinten-Cluster.
I2: im Zustand "armed" stand "FREIGEGEBEN: <ziel>" in der Kopfzeile,
wo 112 px Platz sind und der Text 182 px misst - gezeichnet wurde
"FREIGEGEBE…", bei jedem Ziel gleich. Im Kopf steht jetzt nur noch das
Label, der Zielname als eigene Beiwerkzeile ueber die volle Breite; die
SSID-Zeile gibt dafuer in "armed" ihren Platz ab. Geprueft wird das
gezeichnete Bild (Pixelvergleich gegen eine Referenzzeile), nicht
lines().
I3: der Kommentar an display_handover() begruendete die
Rueckgabewert-Durchreichung damit, dass der Handgriff bei stummem Panel
rot werde. Das stimmt nicht - display.py beendet sich bei fehlendem
Panel/SPI/Pillow mit 0. Der Kommentar sagt jetzt, dass man die Revision
am Panel beurteilt und nicht am Exit-Code.
I4: zwischen "systemctl stop" und "systemctl start" gab es keinen
Signalhandler - ein Strg-C waehrend "display test" (der dokumentierte
Weg, die Revision zu bestimmen, bei falscher Revision haengt das Panel)
liess den Anzeigedienst gestoppt zurueck. trap auf INT/TERM/HUP, das
Wiederanfahren in display_service_resume(), Handler beenden selbst mit
128 + Signalnummer.
M2: HEADER_PHASE_MIN_WIDTH_PX bleibt als Sicherheitsnetz, ist jetzt aber
getestet statt toter Code.
M4: compose() baute je Aufruf fuenf Schriften neu. Prozess-Cache (mit
ImageFont-Modul und FONT_SEARCH_PATHS im Schluessel, damit die Tests
nicht verfaelscht werden) und faules Laden je Rolle: 25,0 ms -> 7,0 ms.
Die Pixeltests laufen zusaetzlich unter Pillow 11.1 mit echtem DejaVu
und unter fuenf weiteren Schriften. Sieben Mutationsproben angewandt,
angesehen und zurueckgenommen. tools/test_host.sh gruen.
Drei Befunde der Hardware-Abnahme: die Migrationsphase stand als eigene
kleine Zeile unter dem Lampennamen und ging dort unter - sie steht jetzt
rechtsbuendig neben dem Modus in der invertierten Kopfzeile (mit
Kuerzung/kleinerer Schrift/Weglassen als gestaffelte Antwort auf eine zu
lange Phase, nie mit Ueberlappung). Die letzte Beiwerkzeile klebte am
unteren Panelrand - durch das Verschieben der Phase in den Kopf bleiben
nur noch fuenf statt sechs Beiwerkzeilen, zusaetzlich durchgesetzt per
BOTTOM_MARGIN_PX in der Abbruchbedingung von compose().
Ausserdem ein am Geraet nachgewiesener Fund: ninlight-display.service
trug Wants=ninlight-station.service NEBEN dem gewollten After= - Wants=
aktiviert die genannte Unit aber, statt nur zu ordnen, und zog den
absichtlich deaktivierten Stationsdienst bei jedem Boot ohne
Zugangspunkt in einen "failed"-Zustand. Wants= entfernt, After= bleibt.
Zwei Befunde der Hardware-Abnahme.
1) Die Schrift war zu klein: compose() zeichnete alles in
ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern
nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big),
compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem
Balken, die wichtigste Zeile gross (Migration: erkannte Lampe,
Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig.
Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit
DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden
Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst
ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt
load_default() ohne Argument, damit eine fehlende Schrift die Anzeige
(und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow
importierbar, die Breitenkuerzung misst weiter in Pixeln.
2) "display test" scheiterte bei laufendem Anzeigedienst mit einem
rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine
gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in
epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin
gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler -
und die Meldung nennt bei belegten Leitungen den Anzeigedienst.
"display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs
und startet ihn danach wieder, aber nur, wenn er vorher lief.
Die Verdrahtung zwischen RefreshPlanner und Panel - Aktion auf Panel-Methode,
was_full=upgraded, record_sleep(), Reihenfolge decide/ausfuehren/zurueckmelden -
ueberlebte jede Mutation, weil der Test eine nachgebaute Schleife trieb. Am
schlimmsten: der "full"-Zweig auf draw_partial() umgebogen haette bedeutet, dass
es auf einem wachen Panel nie wieder einen echten Vollaufbau gibt.
run_daemon() nimmt jetzt Uhr, Panel und Zustandsquelle entgegen (Vorgaben
unveraendert) und wird im Test mit Fake-Uhr und protokollierendem Panel-Doppel
direkt getrieben; die Nachbildung _run_schedule() entfaellt ersatzlos.
Dazu: RefreshPlanner liest die Intervallkonstanten wieder pro Entscheidung statt
sie als Default-Argument zur Importzeit zu binden, record_sleep() verliert den
toten now-Parameter.
Der Reset von partials_since_full beim Einschlafen liess den faelligen
Ausgleichs-Vollaufbau dauerhaft ausfallen: nach einem Buendel Teilaufbauten
schlief das Panel mit dem Geisterbild ein und zeigte es stundenlang weiter,
weil stale nie mehr feuern konnte. Reset ersatzlos gestrichen - der Ausgleich
bleibt ausstehend und wird zum 30-Minuten-Termin nachgeholt; der in Fixrunde 3
beseitigte Bias-Zyklus kommt dadurch nicht zurueck.
Die gesamte Zeitsteuerung steckt jetzt in RefreshPlanner statt in run_daemon()s
Endlosschleife und ist ohne Schleife, ohne time.sleep() und ohne Hardware
pruefbar. Neue Tests spielen sechs Stunden Betrieb durch (statischer Inhalt,
Aenderung alle 3/9/11 Minuten, Buendel-dann-Ruhe) und schliessen die drei vom
Reviewer per Mutation belegten Luecken aus Fixrunde 3.
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.
Fixrunde 2 zu Task 6a: der sleep()-Einbau aus Fixrunde 1 hatte drei neue Critical
erzeugt. epd.sleep() schliesst ueber epdconfig.module_exit() die GPIO-Pins; ein
Teilaufbau direkt danach fasste bei V3 eine bereits geschlossene Pin-Referenz an
(AttributeError, ungefangen, Neustartschleife - binnen Sekunden nach dem Start, V3 ist
die Vorgaberevision) und war bei V2 nach Deep Sleep ohnehin keine belastbare Sequenz
(init(PART_UPDATE) allein setzt nicht die Register, die nur der FULL_UPDATE-Zweig setzt).
Ausserdem setzte der V3-Vollaufbau nie den Referenzpuffer, den ein Teilaufbau zum Diffen
braucht (displayPartBaseImage() statt display()).
Panel bekommt ein internes _awake-Flag: draw_partial() wertet sich selbst zu einem
Vollaufbau auf, wenn das Panel nicht wach ist (frisch erzeugt oder seit dem letzten
Aufbau geschlafen), unabhaengig von der Aufrufreihenfolge. sleep() wird nur noch nach
laengerer Ruhe (10 Minuten ohne Aenderung) gerufen, nicht mehr nach jedem Bild, und ist
selbst idempotent. Dazu: epdconfig faengt jetzt gpiozero.GPIOZeroError statt nur
GPIODeviceError (die PinError-Familie haengt nicht darunter), EnvironmentFile= in der
Unit bekommt ein "-"-Praefix, und die Zeilenkuerzung wandert von einer Zeichenzahl-Grenze
(ungueltig bei einer Proportionalschrift) zu einer echten Pixelbreitenmessung in
compose() - lines() bleibt dafuer ungekuerzt und weiterhin ohne Pillow testbar.
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.
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.