Am Pi diagnostiziert: unter systemd ist HOME leer und cwd "/", lgpio legt seine
Benachrichtigungs-Pipe aber unter "$HOME/.lgd-nfy0" an - das wird "//.lgd-nfy0"
und scheitert an ProtectSystem=strict. gpiozero faellt bis zur experimentellen
NativeFactory durch, die dann mit EINVAL abbricht; display.py faengt das als
DisplayUnavailable ab und beendet sich mit 0, sodass der Ausfall wie ein
fehlendes Panel aussieht statt wie die eigentliche Sandbox-Ursache.
RuntimeDirectory=ninlight-display, WorkingDirectory= und Environment=HOME=
zeigen jetzt auf ein eigenes, beschreibbares /run/ninlight-display - bewusst
getrennt vom RuntimeDirectory der Hauptunit, da dieser Dienst dort nur liest.
Szenario 19 in test_setup_pi.sh prueft die drei Zeilen in der ausgelieferten
Unit; per Mutationsprobe bestaetigt, dass das Entfernen jeder einzelnen davon
den Test rot werden laesst.
Fixrunde 2 zu Task 6b.
- Die Vorbedingung "display.py liegt in der Ablage" steht jetzt VOR dem
ersten sudo install. Vorher stand sie dahinter, und die Meldung "Nichts
installiert" war schlicht unwahr: server.py, products.py, das CLI und
die Stations-Unit lagen dann schon auf dem Pi. Ausgerechnet an dem
Werkbank-Moment, fuer den die Pruefung gedacht ist, log das Skript den
Bediener an. Die Ablage raeumt ein EXIT-Trap jetzt auf beiden Wegen aus
dem Block ab, nicht nur im Erfolgsfall.
- "apt-get update" ist keine Voraussetzung mehr fuer "apt-get install":
update meldet schon dann einen Fehler, wenn eine einzige Quelle nicht
erreichbar ist, und ein Pi, auf dem das Paket aus dem Cache gekommen
waere, bekam so still kein Pillow.
- Die Signalhandler beenden jetzt selbst (130/143/129). Ein Handler ohne
exit laesst bash weiterlaufen - ein Strg-C konnte in "fertig, Exit 0"
muenden. Die Begruendung im Kommentar war falsch und ist korrigiert:
zum Aufraeumen haette der EXIT-Trap allein genuegt.
- test_host.sh ruft die Shell-Suiten aus einer Liste auf, und der
Coverage-Test prueft diese Liste gegen das Dateisystem statt im
Skripttext nach Dateinamen zu suchen - ein nachgestellter Kommentar
hebelte die alte Regel aus. Er deckt jetzt ganz tools/ ab, nicht nur
station/tests.
- Neue Szenarien: Wahrheitsgehalt der Abbruchmeldung, apt-update-Fall,
und ein Strg-C, das aus den Trap-Zeilen des Skripts selbst geprueft
wird.
Fixrunde 1 zu Task 6b.
- setup_pi.sh raeumt die Ablage vor dem scp ab und bricht ab, wenn
display.py dort fehlt. Ein Reststand aus einem abgebrochenen Lauf liess
"scp -r" ins vorhandene Ziel hineinkopieren; die Installationsschleife
ueberspringt Verzeichnisse und fand nichts - der Lauf meldete Erfolg,
aktivierte den Anzeigedienst und hinterliess ein leeres display/:
203/EXEC im 5-Sekunden-Takt bei schwarzem Panel.
- Der Paketschritt steht jetzt hinter der Installation und warnt nur.
Eine Migrationsstation steht typischerweise nicht am Netz; unter set -e
davor hiess ein gescheitertes apt: gar keine Station statt einer Station
ohne Panel. "apt-get update" kommt vor dem install, der Anzeigedienst
startet nach dem Paketschritt.
- setup_pi.sh raeumt display/ auf dem Pi wie deploy.sh auf (im Repo
geloeschte Dateien verschwinden), der Trap haengt auch an INT/TERM/HUP,
und mktemp bekommt eine Vorlage: ohne sie beachtet BSD-mktemp TMPDIR
nicht, und die Temp-Datei landete je nach Plattform woanders.
- test_host.sh ruft test_deploy.sh auf - 95 Zusagen liefen nur bei
Handaufruf, darunter die Deploy-Haelfte der Anzeige-Auslieferung. Der
Coverage-Test deckt jetzt auch die Shell-Suiten ab und zaehlt dabei nur
nicht auskommentierte Zeilen.
- Neue Szenarien fuer alle vier Punkte, darunter die Zusage, dass beim
gescheiterten Schreiben eines Profils keine Datei mit dem WLAN-Passwort
liegenbleibt.
Schritt 8 des Plans: die fertige E-Paper-Anzeige wird endlich ausgeliefert
und verdrahtet, dazu zwei liegengebliebene Dinge.
- deploy.sh/setup_pi.sh uebertragen display/ (mit Panel-Treibern und
HERKUNFT.md) und ninlight-display.service; setup_pi.sh installiert und
aktiviert die Unit und zieht python3-pil nach - ohne Pillow beendet sich
der Anzeigedienst still mit 0, das faellt sonst nirgends auf.
- setup_pi.sh legt auch das Profil fuer den Migrations-Zugangspunkt an
(qconnex-firmware, von der alten Klyqa-Firmware fest vorgegeben, gleiche
Adresse wie das Rettungsprofil). Ohne das scheiterte "migrate on" auf
einem frisch eingerichteten Pi an need_profile. Bestehende Profile
bleiben unangetastet.
- "ninlight-station display test|clear|off"; der Hilfetext erklaert die
Revisionsbestimmung, denn EPD_REVISION laesst sich nur am Testbild
bestimmen.
- "off" nimmt die Anzeige mit: erst Dienst stoppen, dann Panel leeren
(sonst schreibt der Daemon binnen 5 s sein altes Bild zurueck) und beides
vor ap_switch, das bei haengendem NetworkManager abbricht. Ein
stehengebliebenes "MIGRATION" wuerde einen Betriebszustand behaupten, den
es nicht mehr gibt.
- test_host.sh sammelt tools/mkimage als Ganzes ein: die 19 Tests in
test_nl_mkimage.py liefen in keiner Suite mehr mit, darunter die Wache
fuer die Python-3.8-Randbedingung des Build-Containers. Ein neuer Test
haelt die Regel fest, dass keine Testdatei mehr herausfallen kann.
- Nebenbei: die Temp-Datei mit dem WLAN-Passwort wird wieder zuverlaessig
aufgeraeumt, und leere Konfigurationswerte legen kein kaputtes Profil an.
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.
Fixrunde 2 zu "Migrationsimages beim Deploy signieren":
- N1 (Important): das Merge-Skript im SSH-Heredoc von deploy.sh fing beim
Bestandsmanifest nur Lesefehler und kaputtes JSON ab (OSError/ValueError).
War die Datei gueltiges JSON mit falscher Form ("[]", "null", ein String,
oder ein images-Eintrag, der selbst kein Objekt ist), krachte .get() mit
AttributeError - und das VOR der Installation von
server.py/products.py/CLI/Unit und vor dem Neustart, liess einen Pi also
mit neuen Images, aber altem Stationscode zurueck. Jetzt wird alte wie
neue Manifest-Struktur konsequent auf isinstance(dict)/isinstance(list)
geprueft, jede Fehlform faellt auf ein leeres Manifest zurueck statt
abzustuerzen. Neues Szenario 21 deckt alle vier Faelle aus der
Review-Tabelle ab (kaputt, leer, falsch geformtes JSON, kaputter Eintrag).
- N2 (Important): das zusammengefuehrte Manifest hat "ninlight_version"
bisher blind vom neuen Manifest uebernommen, obwohl uebernommene
Alt-Eintraege (deren Images unveraendert liegen bleiben, siehe Fixrunde 1
I1) eine andere Firmware-Version tragen koennen - eine konkrete falsche
Version beim Protokollieren ist schlechter als das vorherige "unbekannt".
Die Version wandert jetzt PRO EINTRAG ins Manifest (deploy.sh erzeugt sie
dort, der Merge behaelt sie); das manifestweite Feld bleibt nur als
Bestandskompatibilitaet und wird nur gesetzt, wenn wirklich alle Eintraege
uebereinstimmen. server.py liest beim Ausliefern jetzt die Version DES
AUSGELIEFERTEN EINTRAGS (Station.image_version(target, variant) ersetzt
manifest_version()) und faellt nur auf das manifestweite Feld zurueck,
wenn der Eintrag selbst keine eigene traegt. test_server.py bekommt einen
neuen Test fuer den Vorrang des Eintrags vor dem manifestweiten Feld,
Szenario 14 in test_deploy.sh prueft jetzt zusaetzlich unterschiedliche
Versionen zwischen zusammengefuehrten Eintraegen und dass das
manifestweite Feld dann fehlt statt falsch zu sein. Die Spec
(docs/superpowers/specs/.../design.md, Abschnitt 4, lokal/nicht
versioniert) ist nachgezogen. Die dadurch ueberfluessig gewordene
"sorted(versions)[0]"-Stelle aus Fixrunde 1 ist entfallen.
- Kleinigkeit: die Remote-Meldung, wenn migrate/ von diesem Rechner leer
ist, behauptet nicht mehr "kein Schluesselverzeichnis" (das stimmt im
Fall "Verzeichnis vorhanden, aber leer" nicht) - sie nennt jetzt keinen
bestimmten Grund mehr, nur die Konsequenz.
- Kleinigkeit: --exclude=manifest.json ist jetzt --exclude=/manifest.json,
konsistent mit dem daneben verankerten --exclude=/migrate.
Alle drei Aenderungen (N1, N2, beide Kleinigkeiten) sind vor dem Fix per
Rot-Probe an einer Kopie von deploy.sh verifiziert: mit den urspruenglichen
Fehlern schlagen Szenario 21 (N1) bzw. Szenario 14 (N2) zuverlaessig fehl.
Bewusst nicht angefasst (Review-Vorgabe): der fehlende HOME-Testfall, und
dass der Merge kein os.path.exists() prueft.
Fixrunde 1 zu "Migrationsimages beim Deploy signieren":
- manifest.json traegt ninlight_version und ein neues built jetzt auf oberster
Ebene statt pro Eintrag, dazu "file" statt der Redundanz einer Version pro
Bild - genau die Form aus der Spec (Abschnitt 4) und aus server.py's
Station.manifest_version(), die vorher immer "unbekannt" gelesen hat, weil
sie ausschliesslich auf oberster Ebene sucht (C1). Ein Testfall schickt das
Manifest jetzt zusaetzlich durch genau diese Funktion statt nur durch
json.load.
- Ein Schluesselverzeichnis, das existiert, aber weder Produktions- noch
Entwicklungsschluessel enthaelt, legt kein STAGE/migrate mehr an - das
vorherige Verhalten liess dafuer trotzdem ein (leeres) migrate/ mit
Manifest entstehen, das der volle rsync-Abgleich dann mit --delete gegen
ein vorhandenes migrate/ auf dem Pi durchgesetzt und es geleert hat (C2).
Aus demselben Grund war die _gz.img-Pflichtpruefung faelschlich
unconditional: fehlte sie, brach sogar ein Deploy ab, der gar nicht
signieren wollte, und nahm die Rettungsimages gleich mit (C3).
- Ein neues MIGRATE_COMPLETE-Flag (nur 1, wenn dieser Lauf mit BEIDEN
Schluesseln signiert hat) entscheidet jetzt, ob der Abgleich auf dem Pi
--delete benutzen und das Manifest ersetzen darf. Bei nur einem der beiden
Schluessel oder einem gezielten Deploy synct migrate/ ohne --delete und
merged das Manifest serverseitig mit dem vorhandenen zusammen, statt es zu
ueberschreiben - vorher raeumte ein Voll-Deploy mit nur dem dev-Schluessel
saemtliche _prod.img weg (C2, Teilfall), und ein gezielter Deploy liess ein
Manifest zurueck, das die unveraendert gebliebenen Ziele nicht mehr
erwaehnte (I1).
- ${HOME:-} statt $HOME beim Ermitteln von SIGN_DIR, damit ein Aufruf unter
`set -u` ohne gesetztes HOME (cron, env -i) nicht unnoetig abbricht, obwohl
nur die Rettungsimages gebraucht werden.
- tests/test_deploy.sh: die Sicherheitsabsicherung aus dem letzten Durchgang
hatte selbst eine Luecke (C4) - "${NINLIGHT_SIGN_DIR:-default}" in
run_deploy() greift nur, wenn die Variable NICHT gesetzt ist, und genau die
ist es, mit der man auf ~/workspace/fw-sign (das ECHTE Schluesselverzeichnis
auf diesem Rechner) zeigt. run_deploy() setzt NINLIGHT_SIGN_DIR jetzt hart
und liest dafuer nur noch die eigene Testvariable TEST_SIGN_DIR, die
deploy.sh nicht kennt und die deshalb nie von aussen vorbelegt sein kann;
am Dateikopf werden zur Sicherheit beide Variablen ge-unset. Verifiziert:
die Suite bleibt gruen, wenn NINLIGHT_SIGN_DIR vorher exportiert ist wie in
einem echten Entwicklerprofil.
- Szenario 15 vergleicht jetzt ALLE Zeilen beider privater Testschluessel
gegen Ausgabe UND Log, nicht nur eine einzelne Zeile des Produktions-
schluessels gegen die Ausgabe.
Vier neue/erweiterte Testfaelle (18-20) plus die Ergaenzung an Szenario 14:
Verzeichnis vorhanden aber leer, Voll-Deploy mit nur dev-Schluessel, echter
Server-Leser fuer das Manifest, gezielter Deploy fuehrt das Manifest zusammen
statt es zu verkuerzen. Jeder der vier Critical-Befunde wurde vor dem Fix per
Rot-Probe am kaputten Code bestaetigt.
deploy.sh signiert pro Ziel das rohe _gz.img (nie das _ota.img) mit den
Produktions- und Entwicklungsschluesseln aus $NINLIGHT_SIGN_DIR und legt
die entstandenen _prod.img/_dev.img samt manifest.json unter migrate/ ab.
Fehlt das Schluesselverzeichnis oder einzelne Schluessel, entfaellt nur
die jeweilige Migrationsvariante mit einer Meldung auf stderr - die
Rettungsimages werden unveraendert weiter ausgeliefert.
migrate/ wird beim rsync-Abgleich auf dem Pi bewusst getrennt von den
_ota.img behandelt: fehlt es lokal (kein Schluesselverzeichnis auf dem
Entwicklungsrechner), bleibt ein vorhandenes migrate/ auf dem Pi
unangetastet statt durch --delete geleert zu werden - derselbe Fehler,
der frueher schon einmal fremde Geraetetypen weggeraeumt hat, diesmal auf
die Migrationsimages angewendet.
test_deploy.sh bekommt acht neue Szenarien fuer die sieben im Task-Brief
verlangten Faelle plus den zusaetzlichen Schutz vor dem geleerten
migrate/. Alle Szenarien erzeugen ihre eigenen Wegwerf-Testschluessel
ueber nl_sign.generate_test_key() und setzen NINLIGHT_SIGN_DIR bei jedem
Aufruf explizit (auch im "ohne Schluessel"-Fall) - der Default
$HOME/workspace/fw-sign zeigt auf diesem Rechner auf echte Schluessel,
und kein Testlauf darf sie je lesen.
Fixrunde 2 zu Task 4:
- N1 (wichtig): file_mode() in test_cli.sh/test_setup_pi.sh probierte
"stat -f '%Lp'" vor "stat -c '%a'" - auf GNU-coreutils (dem Pi) deutet
"-f" dort "Dateisystemstatus" und liefert Muell statt der Rechte-Zahl,
faellt aber trotzdem in den "||"-Zweig durch, der zusaetzlich "644"
anhaengt. Reihenfolge umgedreht und mit einem per gstat erzwungenen
GNU-Testlauf zusaetzlich abgesichert.
- N2 (wichtig): status zeigte nach einem Wechsel zurueck in den
Rettungsmodus weiterhin die letzte Migrationsphase aus einer liegen
gebliebenen control.json (moeglich seit RuntimeDirectoryPreserve=
restart aus Fixrunde 1). Phase wird jetzt nur noch angezeigt, wenn
Modus tatsaechlich "migration" ist.
- N3 (klein): die Schluesselerkennung in setup_pi.sh war rein
zeilenverankert ("^KEY=") und haette einen eingerueckten Bestandswert
als fehlend gewertet - beim naechsten Sourcen der Konfiguration haette
die zusaetzlich angehaengte Vorlagenzeile das eigene Passwort
ueberschrieben. Muster auf fuehrenden Leerraum/export gehaertet.
- N4 (klein): test_setup_pi.sh liess setup_pi.sh ueber den scp-Stub echte
Dateien im /tmp des Entwicklungsrechners anlegen und wieder loeschen.
Neue Variable NINLIGHT_STAGE_DIR biegt das auf eine Sandbox um.
Alle vier Punkte mit Regressionstests, N2 und N3 zusaetzlich per
Mutationsprobe (Fix zurueckgenommen, Test wird rot) verifiziert.
Fixrunde 1 zu Task 4:
- C1 (kritisch): die Unit leerte /run/ninlight-station bei jedem
systemctl restart, weil RuntimeDirectoryPreserve fehlte - genau der
Neustart, mit dem migrate/recovery on den Dienst hochfahren, wischte
die eben geschriebene Modus-/Steuerdatei wieder weg, und der Server
fiel still auf den Recovery-Default zurueck. Unit bekommt
RuntimeDirectoryPreserve=restart, und start_and_verify() prueft nach
dem Neustart zusaetzlich ausdruecklich nach, dass Modus und Phase noch
stimmen.
- I1: status las die Phase aus status.json, die arm/listen (die den
Dienst bewusst nicht neu starten) nie aktualisieren - jetzt kommt sie
aus control.json.
- I2: off pruefte nach dem Herunterfahren nichts nach und konnte
"Station aus" melden, waehrend ein Profil wegen haengendem
NetworkManager weiterhin aktiv war - ap_switch() verifiziert das jetzt
auch im Aus-Fall.
- I3: der Fehlerzweig von ap_switch (Mismatch nach dem Umschalten) war
im bisherigen Test nie erreichbar; der nmcli-Stub kann das jetzt
erzwingen.
- I4: neue Pflichtschluessel blockierten jeden Unterbefehl auf einem
bereits eingerichteten Pi. setup_pi.sh ergaenzt fehlende Schluessel in
einer bestehenden Konfiguration jetzt einzeln, statt sie unangetastet
zu lassen; dafuer musste das Skript per bash-s-Heredocs und
ueberschreibbaren Pfadvariablen erst testbar gemacht werden (neuer
test_setup_pi.sh).
Dazu vier kleinere Punkte aus derselben Review: grep -Fqx statt grep -qx
fuer Profilnamen, install-Stub durch tee+chmod ersetzt (POSIX, kein
Sonderfall mehr fuer macOS), Rechtepruefung auf control.json in den
arm/listen-Testfaellen, stderr sauber von stdout getrennt in den
Warnungs-/Fehlerfaellen.
ninlight-station bekommt recovery/migrate/arm/listen als Unterbefehle statt nur
on/off/status; "on" bleibt Kurzform fuer "recovery on". Eine gemeinsame
ap_switch()-Funktion faehrt vor jedem Wechsel erst jedes andere Stationsprofil
herunter und prueft per nmcli nach, dass auf wlan0 wirklich nur eines aktiv
ist - die beiden Zugangspunkte (Rettung/Migration) schliessen sich damit
gegenseitig aus. Der Modus wandert per /run/ninlight-station/mode an den
(ohnehin neu gestarteten) Dienst statt ueber die Konfigurationsdatei; arm/listen
schreiben die Steuerdatei aus Task 3 mit demselben atomaren 0644-Muster wie
server.py selbst. station.conf.example und die Pflichtschluesselpruefung
bekommen die neuen Migrations- und Anzeige-Schluessel dazu.
Fixrunde 1 zu Task 3 (Migrationsroute):
- C1 (Critical): read_control()/Phasenpruefung/Auslieferung/write_control("listen") liefen
ohne gemeinsame Sperre - zwei gleichzeitige Anfragen (zwei Lampen, oder Erst- und
Range-Resume-Verbindung derselben Lampe) konnten beide phase == "armed" sehen und beide
ausgeliefert bekommen. Neues Station.claim_delivery() liest und verbraucht die Freigabe
jetzt atomar unter station.lock, vor Beginn der Auslieferung; Station.restore_armed()
macht das bei einem Abbruch rueckgaengig.
- I2 (Important): eine unerwartet kurz gelesene Datei zaehlte im Sendeloop als "ok" statt
als "aborted" - relevant geworden, weil dieser Status jetzt entscheidet, ob eine armed-
Freigabe restauriert wird.
- I3 (Important): ein fehlschlagendes write_control() (z.B. schreibgeschuetztes /run)
verschluckte den Fehler und liess "armed" dauerhaft scharf. Der Fehler landet jetzt in
status.json (control_error), und claim_delivery() laesst nur die eine ausloesende
Anfrage noch durch, bis ein Schreibversuch wieder gelingt.
- I4 (Important): die neuen Server-Tests synchronisierten sich per fester Pause statt wie
Station.read_state() bis zu einem Praedikat zu pollen. status()/read_control() im
Testklienten tun das jetzt ebenfalls.
- I5 (Important): resolve_mode()/--mode-file waren ungetestet trotz bindender
Vorrangregel, auf der Task 4 aufbaut - fuenf neue reine Funktionstests.
Dazu: recent[0] -> recent[-1] in allen betroffenen Tests (Brief-Fehler), ein neuer
Rechte-Test fuer control.json (0644 unter restriktivem umask), und eine neue
Nebenlaeufigkeits-Regression fuer C1.
server.py bedient jetzt zwei Modi ueber denselben Dienst: --mode/--mode-file/--control
legen fest, ob ninlight_<target>_ota.img (Rettung) oder qcx_firmware_<pid>_..._ota_light.img
(Migration alter Klyqa-Lampen, via products.parse_request) ausgeliefert wird. Der Modus
kommt wahlweise aus einer Datei statt einem Startparameter, damit Task 4 ihn spaeter ohne
Dienst-Neustart umschalten kann. Die Steuerdatei control.json (Phase auto/listen/armed)
wird bei jeder Anfrage frisch gelesen und atomar mit 0644 geschrieben - eine fehlende oder
kaputte Steuerdatei blockiert eine Migration nie, sondern faellt auf "auto" zurueck. Nach
einer erfolgreichen Auslieferung in Phase "armed" faellt die Freigabe erst nach dem
vollstaendigen Senden auf "listen" zurueck, nie davor, damit ein abgebrochener Transfer die
Freigabe fuer den naechsten Bootversuch stehen laesst. status.json bekommt die
Migrationsfelder aus Spec 5.2 (mode, phase, armed_target, last_seen).
deploy.sh liefert products.py mit aus, sonst startet der Dienst nach dem naechsten Deploy
mit ModuleNotFoundError; test_deploy.sh zieht die Sandbox-Dummy-Datei entsprechend nach.
Neue Tests in test_server.py verwenden eigene server/migration_server-Fixtures samt
Testklient (get/status/set_control/read_control/delete_control/write_raw_control), ohne die
bestehende station-Fixture und ihre Tests anzufassen.
do_POST (und PUT/DELETE/HEAD) riefen reject() auf, ohne eine Logzeile zu
schreiben, und das ueberschriebene log_message() legte damit unbeabsichtigt
auch log_error() lahm (BaseHTTPRequestHandler ruft es standardmaessig auf,
z.B. bei einer kaputten Request-Zeile oder einem Timeout, bevor ueberhaupt
ein Handler wie do_GET erreicht wird). Fuer das einzige Diagnosewerkzeug im
Rettungsfall darf keine Anfrage stumm bleiben.
do_POST loggt jetzt wie do_GET explizit, und log_error() ist separat
ueberschrieben statt implizit mit log_message() stillgelegt zu werden.
Die neuen Tests fuer den 405-Fall lesen den rohen Datei-Deskriptor des
Server-Prozesses statt ueber TextIOWrapper.readline() zu gehen - Letzteres
kann mehr Zeilen aus der Pipe in den eigenen Puffer lesen, als es
zurueckgibt, was select() auf dem naechsten Datei-Deskriptor faelschlich
"nichts da" melden liess. Der Test fuer die kaputte Request-Zeile ruft
log_error() direkt auf: ein echter kaputter Rohsocket wird in dieser
Sandbox von einer Netzwerkschicht abgefangen, bevor er den Server erreicht
(sichtbar an einem untergeschobenen "Server: aiohttp"-Header).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Zwei Build-Verzeichnisse mit demselben Image-Dateinamen (z.B. build/evk und
build/evk-onb, letzteres ohne WLAN-Zugangsdaten) landeten flach im selben
Staging-Namen - die Sortierreihenfolge entschied zufaellig, welches gewinnt,
und die Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp
steht. deploy.sh bricht jetzt ab und nennt beide Fundorte, statt zu raten.
Der Voll-Deploy (deploy.sh <host> ohne target - der in README.md gezeigte
Normalfall) loeschte per rsync --delete zudem die Images aller gerade nicht
gebauten Geraetetypen, ohne je zu sagen, welche. Vor dem Loeschen wird jetzt
per Namensvergleich ermittelt und ausgegeben, welche Dateien verschwinden -
sichtbar statt still, ohne den Ablauf fuer Skripte zu blockieren.
Neue Testszenarien 7-9 in tools/station/tests/test_deploy.sh decken beide
Faelle ab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Der Testschalter zählte bisher pro Anfrage, sodass eine Lampe nach ihrem
Empfangs-Timeout einfach per Range neu verband und ein frisches Budget bekam
– am EVK reichten fünf Reconnects, um trotz --stall-after das komplette
Image zu übertragen. Station verfolgt die ausgelieferten Bytes jetzt pro
Client-IP über alle Anfragen hinweg (Station.reserve_stall_bytes, unter dem
bestehenden Lock), sodass kein erneutes Verbinden das Budget auffüllt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
server.py legt status.json ueber mkstemp() (Modus 0600) + os.replace() an; da
os.replace() den Modus der temporaeren Datei uebernimmt, war die Datei nach jedem
Schreiben nur fuer root lesbar - der Dienst laeuft als root, das CLI liest aber als
normaler Benutzer. os.chmod(tmp, 0o644) vor dem Rename behebt das umask-fest, auch
bei wiederholten Schreibvorgaengen.
ninlight-station status hat den fehlenden Lesezugriff bisher stillschweigend wie
"noch nie ausgeliefert" behandelt (beides fiel durch denselben "-r"-Test). Jetzt wird
zwischen "Datei fehlt" (unauffaellig) und "Datei da, aber nicht lesbar" (Fehlermeldung
auf stderr) unterschieden.
Neuer Test prueft, dass status.json nach dem ersten und nach einem weiteren
Schreibvorgang world-readable bleibt, auch unter restriktivem umask.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Auf dem Rechner des Nutzers liegt im PATH ein ssh, das sich anders
verhaelt als das System-ssh - die Skripte sollen sich darauf nicht
verlassen. setup_pi.sh und deploy.sh riefen ssh, scp und rsync bisher
ohne vollen Pfad auf.
Beide Skripte nutzen jetzt SSH_BIN/SCP_BIN mit Default /usr/bin/ssh bzw.
/usr/bin/scp, ueberschreibbar per NINLIGHT_SSH_BIN/NINLIGHT_SCP_BIN -
dasselbe Prinzip wie NINLIGHT_STATION_ROOT weiter unten in deploy.sh, das
Tests ohne echten Pi ermoeglicht. rsync bekommt "-e $SSH_BIN", damit auch
dessen Transport ueber /usr/bin/ssh laeuft. Die Beispielaufrufe in
setup_pi.sh's Abschlussmeldung und in der README bleiben bewusst bei
schlichtem "ssh" - dort geht es um das, was der Nutzer selbst eintippt.
test: tools/station/tests/test_deploy.sh auf den neuen Override
umgestellt (NINLIGHT_SSH_BIN zeigt auf den ssh-Stub) und um ein Szenario
ergaenzt, das den /usr/bin/ssh- bzw. /usr/bin/scp-Default in beiden
Skripten absichert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
setup_pi.sh sourcte /etc/ninlight-station.conf (0600 root:root) als
unprivilegierter Benutzer und scheiterte damit bei jedem Lauf, bevor
das NetworkManager-Profil angelegt wurde. Die Konfiguration wird jetzt
per "sudo cat" gelesen; ninlight-station macht denselben Fallback fuer
den dokumentierten Aufruf als normaler Benutzer, ohne die Datei fuer
alle lesbar zu machen.
Das AP-Profil wird nicht mehr per "nmcli ... wifi-sec.psk $PSK"
angelegt (das WLAN-Passwort waere sonst fuer jeden lokalen Prozess in
der Kommandozeile sichtbar), sondern als NetworkManager-Keyfile
(0600 root:root) geschrieben und per "nmcli connection load"
eingelesen.
deploy.sh loeschte bei einem gezielten Deploy eines einzelnen
Geraetetyps per "rsync --delete" alle Images anderer Geraetetypen aus
dem live ausgelieferten Verzeichnis. --delete laeuft jetzt nur noch
beim Vollabgleich (kein target-Argument); ein gezielter Deploy ersetzt
nur die genannten Dateien. Dabei zusaetzlich -c (Checksumme statt
Groesse+Zeitstempel) ergaenzt, sonst uebersieht rsync ein neues Image
mit zufaellig gleicher Byte-Groesse. Ausserdem meldet deploy.sh jetzt
einen fehlgeschlagenen Dienst-Neustart als Fehler statt ihn mit "Dienst
laeuft nicht" zu verwechseln.
Stub-Tests unter tools/station/tests/test_deploy.sh (ssh/rsync im PATH
ersetzt, Installationsschritt laeuft gegen ein Sandbox-Verzeichnis)
decken beide Bugfixes sowie die bereits bestehenden Faelle ab: Image
besteht die Trailer-Pruefung nicht, keine Images gefunden.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Fix-Runde 1 nach Review: Station.served und Station.recent wurden ohne
Lock von mehreren Handler-Threads (ein Thread pro Verbindung unter
ThreadingHTTPServer) mutiert - der Normalfall, wenn mehrere Lampen
gleichzeitig laden. Ein threading.RLock schuetzt jetzt Mutation und
Serialisierung nach status.json als eine Einheit.
Ausserdem hatte mein vorheriger Fix fuer das Status-Race einen neuen
Fehler eingefuehrt: "ok" wurde vor dem letzten wfile.write() vermerkt,
sodass ein danach scheiternder Schreibvorgang trotzdem als Erfolg in
status.json stand. record() laeuft jetzt erst, nachdem die Uebertragung
tatsaechlich abgeschlossen oder gescheitert ist; die betroffenen Tests
warten stattdessen auf den Eintrag statt sofortige Konsistenz
vorauszusetzen.
test_stall_after_stops_sending_but_keeps_the_connection prüfte bisher
nur die gesendete Bytezahl und haette einen faelschlich schliessenden
Server nicht bemerkt - ergaenzt um eine explizite Pruefung, dass ein
weiterer recv() in einen Timeout laeuft statt EOF zu liefern.
Neuer Test test_concurrent_downloads_are_all_recorded faehrt 20
gleichzeitige Downloads und prueft served/recent auf Vollstaendigkeit;
er schlaegt zuverlaessig fehl, wenn das Lock entfernt wird (lokal
gegengeprueft: 3 von 10 Laeufen ohne Lock verloren Eintraege, 10 von 10
mit Lock bestanden).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Liefert ausschliesslich Images aus, deren Name dem Muster
ninlight_<target>_ota.img entspricht, unterstuetzt Range-Anfragen fuer
den Wiederaufnahmeversuch der Firmware und schreibt seinen Zustand nach
status.json - die Schnittstelle fuer die E-Paper-Anzeige in 4c.
Behebt zwei Races aus der Referenzimplementierung des Task-Briefs: der
Server trug Erfolg/404 erst nach dem letzten gesendeten Byte in
status.json ein, wodurch ein Client, der die Antwort vollstaendig
gelesen hat, noch den alten Zustand sehen konnte. Ausserdem wurde ein
ganzer 32-KiB-Chunk auch dann komplett gesendet, wenn --stall-after
schon ueberschritten war; die Lesegroesse wird jetzt an die Stall-Grenze
gekappt, und ein Verbindungsabbruch waehrend des Stalls wird per
select/MSG_PEEK erkannt und als "aborted" vermerkt statt den Thread
endlos zu blockieren.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>