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.
nl_sign.py verify/sign gaben bei syntaktisch ungueltigem --pub/--key-Inhalt
(falsches DER/PEM-Format) bisher eine unbehandelte ValueError (pycryptodome
teils IndexError/TypeError) an den Aufrufer durch - die CLI brach dadurch
mit Python-Traceback ab statt mit der vorgesehenen "error: ..."-Meldung und
Exitcode 1. Beide Backends (cryptography und pycryptodome/pycryptodomex)
kapseln das Einlesen des privaten wie des oeffentlichen Schluessels jetzt in
try/except und werfen SignError mit einer festen, schluesselinhaltsfreien
Meldung (via "from None", damit die urspruengliche Bibliotheksausnahme nicht
implizit mitgeschleppt wird). Ein ungueltiger Schluessel kann dadurch nie zu
verify_image() == True fuehren.
Vier neue Tests in test_nl_sign.py: je ein Modul- und CLI-Test fuer kaputtes
--pub und kaputtes --key, die SignError bzw. Exitcode != 0 ohne Traceback auf
stderr festnageln.
nl_sign.py signiert/prueft W600-Images im RSA-4096-PKCS#1-v1.5-Format der
alten Klyqa-Firmware (Check_OTA_Image() in qc_ssl_layer.c): SHA256 ueber die
gesamte Datei, angehaengt als exakt 512 Byte - derselbe Block wie der
ninlight-Trailer, daher die Ablehnung bereits getrailerter Eingaben.
Unterstuetzt sowohl cryptography als auch pycryptodome/pycryptodomex als
Backend. Gegen das echte Klyqa-Schluesselpaar aus ~/workspace/fw-sign
bitgleich verifiziert.
tools/test_host.sh haengt jetzt die Python-Tests aus tools/mkimage/tests
und tools/station/tests an ctest an.
Zwei neue Einstellungen, beide wie fade_in/fade_out ueber
POST /api/v1/device/state les- und schreibbar und sofort wirksam:
- transition_default (Voreinstellung 300 ms) fuellt die Transition fuer
Farb-, Weiss- und Helligkeitswechsel bei eingeschalteter Lampe, wenn der
Aufruf kein eigenes "transition" mitbringt. Beim Ein- und Ausschalten
behalten fade_in/fade_out weiterhin Vorrang.
- wake_on_change (Voreinstellung aus) schaltet die Lampe bei einer Farb-,
Weiss- oder Helligkeitsaenderung ein, wenn sie aus war. Ein
ausdrueckliches "on": false im selben Aufruf gewinnt immer.
Beide Felder haengen am Ende von nl_settings_t, damit bestehende Lampen
ueber die Laengentoleranz von read_rec() ihre echten Voreinstellungen
bekommen statt Nullen. nl_ctrl_set_fades() heisst jetzt
nl_ctrl_set_options().
Dazu ein Treiberfehler, der erst an der migrierten GU10 RGB auffiel: der
WY-Zweig von nl_sm2135eh_set() schrieb nur Header und WY-Daten, nie die
RGB-Datenregister. Der Chip behielt also die zuletzt gesetzte Farbe, und
bei einem direkten Wechsel Farbe -> Weiss (ohne Zwischenschritt ueber
dunkel) schaelte sie sich beim Ausblenden wieder frei. Der Header sendet
jetzt die - in Weiss immer nullwertigen - RGB-Daten mit.
Die Bruno-Collection zeigt Transition jetzt zusammen mit einem Farb- und
einem Weisswechsel im selben Aufruf und hat einen Request fuer die
beiden neuen Einstellungen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bei allen Kanaelen auf null schrieb der Treiber nur Nullen ins
Kanal-Register, liess den Chip aber aktiv - er laesst dann genug
Reststrom durch, dass die Lampe schwach weiterglimmt. An einer
migrierten GU10 RGB gemessen. Jetzt wird Bit 5 (Standby) gesetzt.
Die alte QConnex-Firmware hatte diese Logik, aber auskommentiert
(qcl_lamp.c) - das Verhalten ist von dort geerbt. Lampen an direkten
PWM-Pins (G95, CW-Varianten) sind nicht betroffen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CLI und beide Skripte (setup_pi.sh, deploy.sh) rufen sudo nicht-interaktiv
auf; ohne passwortloses sudo fuer den SSH-Benutzer bricht der gesamte
Ablauf mit einem Passwort-Prompt ab, den nichts davon behandeln kann. Das
war bisher eine unausgesprochene Voraussetzung.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Die Spec (§9) verlangt beide Betriebsregeln in der Ausgabe von "on": die
Erinnerung ans Ausschalten stand schon da, die zum eigenen Passwort fehlte.
Bleibt PSK auf dem oeffentlichen Default 'ninlight-recovery' stehen, kann
jeder in Funkreichweite, der ihn kennt, jeder Lampe beliebige Firmware
unterschieben - das wird jetzt bei jedem Einschalten sichtbar gemacht.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Die Antwort gab bisher nur die gespeicherte SSID aus. Wurde eine Lampe nie
per REST onboardet, ist die leer, und die Lampe laeuft in Wirklichkeit auf
den einkompilierten NINLIGHT_WIFI_SSID/-PASSWORD (siehe app_main.c) -
GET system/settings suggerierte damit "kein WLAN konfiguriert", obwohl die
Lampe zuverlaessig verbunden ist, und widersprach scheinbar system/info.
Additiv, ohne die Bedeutung von "ssid" zu aendern: wifi.source ist jetzt
"stored" oder "compiled". Spec (§7.2) und bruno/ninlight.yml dokumentieren
das Feld, ein neuer Hosttest deckt den compiled-Fall ab.
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 Abschnitt beschrieb pauschal ein rund siebenminütiges TCP-Timeout ohne
Wiederaufnahme. Heute gemessen gilt das nur, wenn die Gegenstelle komplett
stirbt (Prozess weg). Bleibt sie erreichbar und liefert nur nichts mehr,
greift das 5-s-RECV_TIMEOUT der Firmware, und die Lampe setzt per Range
sauber fort – am EVK fünfmal hintereinander bis das Image vollständig war.
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>
Auf dem Überspring-Pfad (Recovery lehnt ein Image ab, weil es bereits die
laufende Version trägt) bleibt s_error absichtlich NULL, landete aber
trotzdem in printf("ota: failed (rc=%d, %s)\r\n", rc, s_error) - ein
Nullzeiger an %s ist undefiniertes Verhalten und wurde bislang nur zufällig
von newlib als "(null)" abgefangen. Zusätzlich widersprach die Meldung
"failed" der direkt folgenden Zeile "recovery: image already up to date,
continuing normal boot".
ota_task() unterscheidet jetzt explizit zwischen dem Skip-Fall (eigene,
ehrliche "ota: skipped"-Zeile) und dem echten Fehlschlag (bestehende
"ota: failed"-Zeile, weiterhin mit dem "download"-Fallback für ein leeres
s_error). rc bleibt reiner Diagnosewert im Log, da das SDK den Transfer in
beiden Fällen abbricht, sobald der Trailer-Hook das Image ablehnt.
Restliche Leser von s_error/nl_ota_last_error() (nl_recovery.c, nl_api.c)
waren bereits NULL-sicher und mussten nicht angepasst werden.
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>
tools/mkimage/uv.lock ist getrackt, tools/station/uv.lock war es nicht -
ohne die Datei sind die Testlaeufe der Station nicht reproduzierbar.
Co-Authored-By: Claude Opus 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>
"systemctl is-active" schreibt den Zustand ("inactive", ...) auf stdout
und liefert dabei einen Rueckgabewert ungleich 0 - der "|| echo inaktiv"-
Zweig lief also zusaetzlich mit, und beide Zeilen landeten in der
Ausgabe. Ausserdem endete "status" ueber den letzten Befehl im
case-Zweig faktisch mit dessen Exit-Code, der 1 war, sobald noch nie
ausgeliefert wurde (kein /run/ninlight-station/status.json) - das machte
"status" in jedem Skript, das den Exit-Code abfragt, unbrauchbar.
Beide Stellen behoben: der Dienstzustand wird nur noch einmal ausgegeben,
und der Zweig endet mit explizitem "exit 0", sobald die Auskunft erteilt
werden konnte.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CMake ruft nl_mkimage.py bei jedem Firmware-Build ueber das plain
"python3" im Docker-Build-Container auf (Python 3.8.10), unabhaengig von
den Werkzeugen auf Mac/Pi, die Python >= 3.11 voraussetzen. Die zuletzt
hinzugekommene Annotation "expect_device: str | None" wird dort zur
Laufzeit ausgewertet und wirft "TypeError: unsupported operand type(s)
for |: 'type' and 'NoneType'", was jeden Firmware-Build abbrechen liess.
"from __future__ import annotations" (wie in tools/station/server.py)
sorgt dafuer, dass Annotationen nur noch als Text existieren und nie
ausgewertet werden - der Rest der Datei kam ohne weitere 3.8-Stolpersteine
aus.
test: zwei Regressionstests ergaenzt, die ohne installiertes Python 3.8
auskommen: einer prueft per AST, dass der future-Import direkt nach dem
Docstring steht, der andere laesst vermin (dev-Dependency) die
Mindestversion aus Syntax/Stdlib-Nutzung ableiten. vermins
"union-types"-Feature wird bewusst nicht aktiviert, weil es "str | None"
unabhaengig vom future-Import als 3.10-Anforderung meldet - das wuerde
ausgerechnet den korrekten Fix durchfallen lassen.
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>
setup_pi.sh ist idempotent und laesst eine vorhandene Konfiguration in
Ruhe; deploy.sh prueft jedes Image mit nl_mkimage --verify, bevor es
etwas uebertraegt.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Critical 1: path.read_bytes() fiel auf nonexistent file oder Directory mit
Traceback statt einer Fehlerzeile. Jetzt OSError-Familie abgefangen.
Critical 2: --device/--version mit Leerzeichen wurden stillschweigend
akzeptiert; das zerlegt das Ausgabeformat und bricht Task 4s Skript.
Jetzt whitespace beim Bauen abgelehnt.
Important 1: Gerätefeld muss in C wie in Python null-terminiert sein.
Die Firmware checkt das unabhängig von --expect-device; Python nur mit
Flag. Jetzt Terminierung immer verlangt.
Important 2: Modi waren nicht exklusiv; --verify mit Baumodus-Argumenten
wurde stillschweigend ignoriert. Jetzt Fehler statt falschem Vertrauen.
Neue Tests für alle vier Szenarien + nonexistent file, directory, empty
file, Trailer ohne Payload, Mode-Mischung.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
deploy.sh soll kein Image auf die Station schieben, das jede Lampe
ablehnen würde. Die Prüfung sitzt in nl_mkimage, weil dort das
Trailer-Layout ohnehin steht - eine zweite Kopie derselben Offsets
wäre genau die Doppelung, die beim Abweichen jedes Update killt.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- 'on' fing bisher nur einen bereits laufenden, aber toten Dienst ab. Scheiterte
schon der Aufruf "systemctl start" selbst (z.B. start-limit-hit nach einer
Absturzserie durch Restart=always), beendete set -eu das Skript dort, bevor
die Lebendigkeitspruefung je erreicht wurde - der eben hochgefahrene
Zugangspunkt blieb offen ohne Server dahinter stehen. Der Startaufruf wird
jetzt selbst abgefangen und faehrt im Fehlerfall dieselbe Rueckabwicklung wie
die is-active-Pruefung. "systemctl reset-failed" vor dem Start verhindert,
dass eine fruehere Absturzserie den naechsten ehrlichen Versuch blockiert.
- Stirbt der Dienst nach einem erfolgreichen Start, stoppt das Skript ihn jetzt
explizit, bevor es den Zugangspunkt herunterfaehrt und "abgeschaltet" meldet -
sonst haette ein mit Restart=always weiterlaufender Dienst im Hintergrund
einen saubereren Zustand suggeriert, als tatsaechlich vorlag.
- ninlight-station.service setzt StartLimitIntervalSec/StartLimitBurst jetzt
explizit statt sich stillschweigend auf systemds Vorgabewerte zu verlassen,
mit Begruendung als Kommentar: ohne Zugangspunkt kann server.py nicht binden
und stirbt sofort, Restart=always soll das nach kurzer Zeit ehrlich aufgeben
statt das Journal zu fluten.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- 'on' prueft nach dem Start wirklich, ob der Dienst aktiv ist (Type=simple
meldet sonst schon Erfolg, wenn der Prozess nur gestartet wurde), und faehrt
den Zugangspunkt bei einem toten Server wieder herunter statt ihn offen mit
bekanntem Passwort ohne Server dahinter stehen zu lassen.
- 'off' faengt einen fehlschlagenden Wechsel zurueck ins Heim-WLAN ab statt
unter set -eu mit nmclis Rohausgabe abzubrechen.
- Fehlende Schluessel in der Konfiguration werden nach dem Sourcen explizit
geprueft und mit einem Verweis auf setup_pi.sh gemeldet statt mit
"parameter not set" abzubrechen.
- station.conf.example verwendet einfache statt doppelter Anfuehrungszeichen:
POSIX ". datei" wertet $ und ` auch in doppelten Anfuehrungszeichen aus,
ein Passwort mit "$1" waere sonst lautlos durch ein Shell-Argument ersetzt
worden. systemds EnvironmentFile= fuehrt ohnehin nie eine Shell aus und
entfernt einfache wie doppelte Anfuehrungszeichen gleichermassen.
- ninlight-station.service: BindsTo=NetworkManager.service entfernt (ein
dadurch ausgeloester Stop gilt fuer systemd als sauber und wird von keiner
Restart=-Politik erneut gestartet - ein NetworkManager-Neustart haette den
Server sonst still und dauerhaft mitgerissen); stattdessen nur
After=NetworkManager.service fuer die Startreihenfolge und Restart=always,
damit der Prozess nach einem Absturz (z.B. Bind-Fehler) automatisch
wiederkommt.
- Trailer-Reader im 'status'-Zweig nutzt jetzt errors="replace" wie
trailer_version() in server.py, statt bei nicht-ASCII-Bytes im
Versionsfeld abzustuerzen.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Die Station ist bewusst nicht dauerhaft an - 'ninlight-station on'
schaltet Zugangspunkt und Dienst gemeinsam ein, 'off' schaltet wlan0
zurueck ins Heim-WLAN. Der Dienst laeuft als root (Port 80 und die
DHCP-Leases fuer 4c) und bekommt dafuer systemd-Einschraenkungen.
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>
Lehnte die Recovery ein Image ab, weil es bereits die laufende Version
traegt, meldete die Firmware das als last_ota_error=trailer. Wer eine
Lampe im Feld ausliest, haette dort einen Trailer-Fehler gesehen, wo in
Wahrheit nichts passiert ist - und die Meldung in der Konsole lautete
"update failed". Das bewusste Ueberspringen hat jetzt einen eigenen
Zustand (nl_ota_skipped()), setzt keinen Fehlerwert und wird als
"image already up to date" protokolliert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein zweiter fw_update waehrend eines laufenden Downloads setzte s_error
auf "busy". Da ota_task() nur bei leerem Fehlerfeld auf "download"
zurueckfaellt, wurde ein spaeter gescheiterter Transfer danach als
"busy" gemeldet statt als "download". Die Abweisung hat kein eigenes
Ergebnis - sie wird der API mit 409 gemeldet, last_ota_error bleibt beim
Ergebnis des laufenden bzw. letzten Updates.
Dazu die Doku auf das am EVK gemessene Verhalten korrigiert: stirbt die
Gegenstelle mitten im Download, haengt der Lesevorgang rund sieben
Minuten in einem TCP-Timeout und bricht ohne Range-Resume ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sieben Punkte bestanden. Der zweite fw_update nach einer Ablehnung ging
erst nach dem SHA256-Kontext-Fix. Die einmalige lwIP-pbuf-Assertion beim
verfälschten Image ist als offene Beobachtung vermerkt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der fwup-Task überlebt jede Update-Sitzung. Weil nl_sha256_init() am
Taskanfang stand, benutzte ab dem zweiten Update-Versuch pro Boot ein
bereits finalisierter Kontext weiter - der Trailer-Hash konnte dann gar
nicht mehr passen und jedes weitere Update scheiterte mit
last_ota_error=hash. Am EVK gefunden: nach einer Ablehnung war ein
Reboot nötig, bevor überhaupt wieder ein Update gelingen konnte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sdk/PATCHES.md, README.md und docs/hardware-checklist.md beschrieben teils
einen ueberholten Stand: die Watchdog-Einschaetzung in PATCHES.md Punkt 4
stammte noch von vor dem Drei-Heartbeat-Gate in app_main.c und vor der
Zeitobergrenze in nl_recovery.c; README behauptete eine harte Obergrenze
ueber dem gesamten Download statt nur ueber einem echten Stillstand; der
Boot-Scan ist aktiv (~3 s), nicht passiv wie an einer Stelle der Spec
beschrieben (Spec bleibt unveraendert). PATCHES.md Punkt 1 ergaenzt den
sys/nl_ota.h-Include in wm_fwup.c, Punkt 5 den fls_lock-printf.
hardware-checklist.md: Recovery-Zeile auf "Gegenstelle sendet nichts mehr"
praezisiert; die beiden Werksreset-Haken aus Plan 1 zurueckgesetzt, weil der
vorgelagerte Recovery-Scan das Boot-Timing verschiebt und sie neu geprueft
werden muessen; acht neue Pruefpunkte fuer Plan 3 ergaenzt (Zweitversuch
nach Ablehnung, REST-Erreichbarkeit waehrend/nach Ablehnung, Lichtverhalten
ueber OTA mit pob=off/on, fw_version gegen Trailer, echter Rettungsfall,
Rettungsstation nach Erfolg angelassen, AP-Modus + auth_all) sowie ein
Betriebshinweis zu NINLIGHT_RECOVERY_PASSWORD/NINLIGHT_TOKEN fuer den
Flottenbau.
Wie beim s_fls2_lock-Fehlerpfad (T1, Plan 2) blieb auch ein Fehlschlag beim
Anlegen des primaeren fls_lock-Semaphors unsichtbar, weil TLS_DBGPRT_ERR in
diesem Build wegkompiliert ist. tls_fls_init() haette TLS_FLS_STATUS_ENOMEM
stumm zurueckgegeben. Ergaenzt außerdem den Zeitobergrenzen-Kommentar in
nl_recovery.c: die Obergrenze schuetzt nur gegen einen echten Stillstand,
nicht gegen einen langsamen, aber laufenden Download - wm_fwup.c bedient den
Watchdog bei jeder empfangenen Nachricht selbst.
NINLIGHT_BUILD_DATE entstand als string(TIMESTAMP) zur Konfigurationszeit,
während die Version seit bf8f76e bei jedem Build neu geschrieben wird -
system/info.build_date konnte dadurch tagealt sein. Das Datum wandert in
cmake/write_version.cmake und damit in denselben generierten Header wie die
Version.
nl_settings_save() rechnete die CRC über [0, offsetof(rec_t, crc)) und legte sie
dort ab, read_rec() liest sie bei sizeof(rec_hdr_t) + hdr.length. Beide Offsets
stimmen nur überein, solange sizeof(nl_settings_t) ein Vielfaches von 4 ist
(aktuell 204). Ein zusätzliches uint16_t - in Plan 4 (Migration) sehr
wahrscheinlich - macht 206, und jeder gespeicherte Record fiele beim nächsten
Boot still durch die CRC: WLAN, Token und Name wären auf allen Lampen weg.
Geschrieben wird jetzt explizit bei NL_SETTINGS_CRC_OFF(length), also exakt an
der Leseposition; dazu eine Übersetzungszeit-Zusicherung, dass die Nutzdaten
direkt hinter dem Header beginnen (die CRC läuft auf beiden Seiten über einen
zusammenhängenden Block).
Test: test_crc_offset_matches_reader prüft die Symmetrie unabhängig von der
Strukturgröße. Gegenprobe mit einem zusätzlichen uint16_t im Struct: mit der
alten Schreibposition fallen vier Settings-Tests, mit der neuen keiner.
Schlägt socket(), bind() oder listen() fehl, schlief die äußere Schleife eine
Sekunde und versuchte es erneut, ohne nl_http_beat zu erhöhen. Da app_task den
Watchdog nur füttert, wenn alle drei Heartbeats vorankommen, resettete der Chip
nach 20 s, bootete, scheiterte wieder - dauerhaft. Der Retry selbst ist die
Fehlerbehandlung, nicht der Reboot.
C1: nl_recovery_try() verglich nichts mit der laufenden Firmware. Vor einer
laufenden Rettungsstation - laut Spec 9 der Normalfall, solange eine Charge
migriert - lud die Lampe bei jedem Boot dasselbe Image erneut und startete neu.
nl_ota_start_recovery() setzt jetzt ein Flag, das nl_fwup_verify_tail() nur im
Recovery-Pfad auswerten lässt: Trägt der Trailer dieselbe Version wie die
laufende Firmware (nl_trailer_version(), bisher ungenutzt), wird das Image
abgelehnt und der Boot läuft normal weiter. Ein absichtliches Neuflashen
derselben Version über die REST-API bleibt möglich. Kein neuer Wert im
last_ota_error-Vokabular (Spec 8.2); unterschieden wird über die Konsole.
C1/I2: boot_ms wurde erst nach nl_recovery_try() gesetzt und der
Power-Cycle-Zähler erst 5 s danach gelöscht - bei erfolgreicher Recovery also
nie. Nach NL_FACTORY_RESET_BOOTS Runden folgte ein Werksreset. boot_ms startet
jetzt am Anfang von app_task (damit gilt wieder das in Spec 3.4/5.5 zugesagte
5-s-Fenster statt faktisch ~8 s), und nl_app_clear_reset_counter() löscht den
Zähler zusätzlich, sobald die Rettungsstation gefunden ist.
I3: tls_sys_auto_mode_run() stößt direkt nach UserMain() einen SDK-eigenen
Verbindungsversuch an; das dabei entstehende NETIF_IP_NET_UP war in
recovery_join_event() nicht vom eigenen Join zu unterscheiden, die Lampe hätte
im falschen Netz geladen. nl_wifi_disable_sdk_auto_connect() schaltet das Flag
ab und kappt eine bestehende Verbindung jetzt am Anfang von nl_recovery_try();
zusätzlich prüft nl_wifi_join_blocking() über tls_wifi_get_current_bss(), dass
die Verbindung wirklich zur erwarteten SSID gehört.
Spec 8.2 verlangt, dass ein durch ein Update ausgelöster Neustart nicht wie ein
Stromausfall wirkt: Das Flag ota_pending wurde bisher nur geschrieben und beim
nächsten Boot wieder gelöscht, nl_boot_state() wendet aber weiterhin das normale
power_on_behaviour an. Eine eingeschaltete Lampe mit pob=off kam nach einem Update
aus zurück, mit pob=on umgekehrt.
nl_boot_state() bekommt dafür ein Argument after_ota; ist ein gespeicherter
Zustand vorhanden, wird er unverändert übernommen. Ohne gespeicherten Zustand
gelten weiterhin die normalen Regeln.
Hosttests: vier neue Fälle in test_boot_state.c (pob=off/on über ein OTA,
Sanitisierung bleibt aktiv, kein gespeicherter Zustand).
Vorher traf die Regex das erste Stringliteral im generierten Header, unabhängig
vom Makronamen. Zusätzlich bricht ein leerer Regex-Treffer jetzt hörbar ab,
statt eine leere Version stillschweigend in den OTA-Trailer zu schreiben.
read_rec() füllte den Puffer bisher mit memset(0) statt mit
nl_settings_defaults() vor, bevor die tatsächlich vorhandenen hdr.length
Bytes darübergeschrieben werden. Ein gekürzter Record, dessen Länge vor
fade_in_ms/fade_out_ms endet, aber token noch enthält, lief damit mit
fade_in_ms=0/fade_out_ms=0 statt der spezifizierten 500 ms - genau der Fall,
für den die Längentoleranz gedacht war. Die davor eingebaute "leerer Record"
Sonderbehandlung in nl_settings_load() (fade_in_ms==0 && fade_out_ms==0 &&
token[0]=='\0' -> volle Defaults) ist dadurch überflüssig geworden und
entfernt.
test_load_accepts_shorter_record kürzt jetzt bis vor fade_in_ms und prüft
explizit auf die echten Defaults (500/500/0) statt nur auf Felder vor dem
gekürzten Bereich. test_load_rejects_longer_record verzichtet auf die tote
CRC-Neuberechnung, da read_rec() zu lange Records schon anhand der Länge
verwirft, bevor Payload oder CRC gelesen werden.
- wm_internal_fls.c: TLS_DBGPRT_ERR ist in diesem Build wegkompiliert, daher
printf() für die fehlgeschlagene s_fls2_lock-Anlage; PATCHES.md korrigiert.
- nl_wifi_w600.c: der SoftAP-Backstop (Zerstören eines unerwarteten SDK-Softap)
lief bisher innerhalb von lock()/unlock() - tls_wifi_softap_destroy() und
clear_softap_param() greifen aber auf SDK-Zustand zu, nicht auf s_sm/die
Credential-Puffer, die s_lock eigentlich schützt. Jetzt wird im Lock nur noch
ein lokales Flag gesetzt, die SDK-Aufrufe laufen danach.
- clear_softap_param() liest TLS_PARAM_ID_WPROTOCOL jetzt aus SRAM (FALSE)
statt Flash (TRUE) - das Schreiben bleibt weiterhin geflasht.
- app_main.c füttert den Watchdog erst, wenn Licht-, Netz- und HTTP-Task
jeweils seit dem letzten Feed einen Fortschritt gemeldet haben. Dafür
bekommen nl_wifi_w600.c (nl_net_beat) und nl_http_server.c (nl_http_beat)
je einen volatile Heartbeat-Zähler; der HTTP-Listen-Socket bekommt ein
SO_RCVTIMEO von 2 s auf accept(), damit er auch ohne Anfragen tickt.
Das Feld lieferte ohnehin nur den festen String "unknown" - kein SDK-Aufruf
lieferte je einen echten Reset-Grund. README/hardware-checklist erwähnten
es nicht.
NINLIGHT_FW_VERSION kam bisher aus einer configure-Zeit-Variable, die nur bei
erneutem CMake-Lauf aktualisiert wurde - nach einem Commit ohne Re-Konfiguration
zeigte Banner/system/info also eine veraltete Git-Version. cmake/write_version.cmake
schreibt build/generated/nl_version.h bei jedem Build frisch (add_custom_target ALL),
app_main.c/nl_app_api.c/nl_mdns.c inkludieren das Makro jetzt von dort statt aus
target_compile_definitions.
read_rec() akzeptiert jetzt Records mit hdr.length <= sizeof(nl_settings_t):
CRC wird nur über die tatsächlich geschriebenen Bytes geprüft, fehlende Felder
bleiben auf 0 (Default). Ein zu langer (unbekannter) Record wird weiter
abgelehnt. Ein durch massive Kürzung "leer" gelesener Record (fade_in_ms,
fade_out_ms und token allesamt 0) fällt komplett auf die Defaults zurück.
Neues Feld ota_pending (nach fade_out_ms) für den kommenden OTA-Ablauf.
M9: Hardware-Checkliste um Einträge für C1 (verschachteltes JSON, 400,
Lampe läuft weiter), C2 (Onboarding, 60 s warten, Reboot + Router-Neustart
-> AP darf nicht wiederkommen) und einen Hinweis auf den Build ohne
NINLIGHT_WIFI_SSID für Onboarding-Tests ergänzt. `bri 30`-Eintrag nennt
jetzt explizit den REST-Weg (`{"brightness":30}`) neben der Konsole.
README: klargestellt, dass der Onboarding-Aufruf ohne Authorization-Header
nur im unkonfigurierten AP funktioniert - im Fallback-AP (Zugangsdaten
bereits vorhanden) ist der Token-Header wie bei jedem anderen Endpunkt
erforderlich.
M2: WLAN-Passwort muss leer (offenes Netz) oder 8..64 Zeichen lang sein
(400 "invalid password" bei 1..7 Zeichen). CMake prüft NINLIGHT_WIFI_PASSWORD
ebenso (0 oder 8..63 Zeichen) und der Zeichen-Blacklist-Check bekommt `;`
dazu.
M4: Ein Authorization-Wert, der länger als NL_TOKEN_LEN-1 (64) ist, wird
von nl_http_parse() nicht mehr stillschweigend abgeschnitten, sondern setzt
`auth_too_long` in nl_http_request_t; nl_api_handle() behandelt eine solche
Anfrage als unauthentifiziert (außer bei offenem Zugriff), damit ein
abgeschnittener Wert nie zufällig auf ein kürzeres Token passt.
M5: get_int() prüfte die (long)-Cast-Gleichheit vor dem Bereichscheck -
bei sehr großen oder NaN-Werten ist der Cast von double nach long
undefiniertes Verhalten. Bereichs- und NaN-Check laufen jetzt vor dem Cast.
T1: sdk/Platform/Drivers/internalflash/wm_internal_fls.c: tls_fls_init()
ignorierte den Rückgabewert von tls_os_sem_create(&s_fls2_lock, 1) - jetzt
eine TLS_DBGPRT_ERR-Meldung, falls das fehlschlägt (dokumentiert in
sdk/PATCHES.md).
Tests: test_api.c (Passwortlängen, auth_too_long, 1e300-Brightness),
test_http.c (auth_too_long wird gesetzt). Für M5 zeigt der Host-Test kein
sichtbares RED, weil der (double)->(long)-Cast auf dieser Plattform/diesem
Compiler keinen Crash auslöst (implementation-defined statt UB-Crash) - der
Fix wird trotzdem angewendet, da er auf dem Zielcompiler (arm-none-eabi 4.9)
nicht garantiert sicher ist.
C2: tls_wifi_softap_create() setzt das SOFTAP-Bit (IEEE80211_MODE_AP = 4)
in TLS_PARAM_ID_WPROTOCOL und schreibt es in den Flash; tls_wifi_softap_destroy()
löscht es nie wieder. Solange das Bit gesetzt bleibt, erstellt
sys_net_status_changed() (sdk/Platform/Sys/tls_sys.c:519-525) bei jedem
NETIF_IP_NET_UP über tls_auto_reconnect_softap() erneut den Onboarding-AP
mit den SDK-eigenen (öffentlichen Default-)Parametern - unabhängig vom
Auto-Connect-Flag. Nach jedem Onboarding/Fallback käme der AP
`ninlight-<mac4>` mit dem öffentlichen Default-Passwort bei jedem
Reconnect/Reboot zurück, offen fürs LAN (SDK-NAPT).
Verifiziert in sdk/Platform/Sys/tls_sys.c, sdk/Include/Platform/wm_params.h
und sdk/Platform/Common/Params/wm_param.c:
- TLS_PARAM_ID_WPROTOCOL (10) ist ein u8, TLS_PARAM_IEEE80211_SOFTAP == 4 ==
IEEE80211_MODE_AP (sdk/Include/WiFi/wm_wifi.h).
- tls_param_get()/tls_param_set(id, argv, to_flash) - genau die Signaturen
aus dem Finding.
- tls_param_to_default() (tls_param_load_factory_default() + param_to_flash())
arbeitet ausschließlich auf `struct tls_sys_param` (Wi-Fi/Netzwerk-Konfig);
es liest/schreibt nirgends die MAC-Adresse oder RF-Kalibrierdaten - die
liegen in einer komplett getrennten, efuse-gestützten FT_PARAM_ST-Struktur
in sdk/Platform/Drivers/efuse/wm_efuse.c. tls_param_to_default() ist damit
für den Werksreset sicher.
Fix (nl_wifi_w600.c):
1. clear_softap_param(): liest TLS_PARAM_ID_WPROTOCOL, löscht bei gesetztem
IEEE80211_MODE_AP-Bit und persistiert das (to_flash=TRUE). Aufgerufen in
nl_wifi_start() (vor allem anderen) und nach jedem tls_wifi_softap_destroy().
2. nl_wifi_start(): zerstört einen bereits vom SDK selbst erzeugten SoftAP
(tls_wifi_softap_get_state() != WM_WIFI_DISCONNECTED), bevor unsere eigene
State Machine startet.
3. Backstop im Netzwerk-Task: NETIF_WIFI_SOFTAP_SUCCESS, während die State
Machine sagt, der AP solle unten sein (s_sm.ap_up == false) -> AP wieder
zerstören und Param erneut löschen (geloggt).
4. Werksreset (nl_app_factory_reset, src/app_main.c): setzt zusätzlich
tls_param_to_default() (siehe Verifikation oben) und dokumentiert das
inline.
Nebenbei im selben Treiber erledigt, weil sie denselben Codepfad betreffen:
- M2: connect_station() loggt den Rückgabewert von tls_wifi_connect(), wenn
er nicht WM_SUCCESS ist.
- M3: start_ap() merkt sich einen fehlgeschlagenen tls_wifi_softap_create()
(`s_ap_create_pending`) und wiederholt ihn alle 5 s, solange die State
Machine den AP oben haben will.
Nicht host-testbar (bindet SDK-Header ein); über den Firmware-Build
verifiziert (baut sauber für evk, keine neuen Warnungen).
I2: SO_RCVTIMEO=5s war ein Timeout pro recv(), nicht für die gesamte
Anfrage. Ein Client, der 1 Byte alle 4,9 s schickt, konnte den einzigen
HTTP-Server-Task damit stundenlang belegen (Spec §7.2 will 5 s pro Header).
serve() rechnet jetzt eine absolute Deadline (nl_app_now_ms() + 5000 ms)
und setzt SO_RCVTIMEO vor jedem recv() auf die verbleibende Zeit (mindestens
1 ms); ist die Deadline überschritten, wird die Verbindung sofort verworfen.
Nicht host-testbar (nl_http_server.c bindet SDK-Sockets ein); über den
Firmware-Build verifiziert.
I1: fail_test() kehrte ohne gespeicherte Zugangsdaten stillschweigend nach
AP_ONBOARDING zurück, ohne den Stationsversuch zu trennen; ein danach doch
noch eintreffendes CONNECTED wurde in AP_ONBOARDING ignoriert, aber der
Treiber rief nl_mdns_on_ip_up() unabhängig vom Zustand auf - die Lampe
landete offen im LAN, ohne dass Zugangsdaten gespeichert wurden.
nl_wifi_actions_t bekommt ein neues Feld `disconnect`:
- fail_test() ohne Zugangsdaten setzt es (Timeout/Fehler beendet den
Stationsversuch).
- Ein CONNECTED-Event in AP_ONBOARDING gilt als Ausreißer eines bereits
gescheiterten Versuchs und wird ebenfalls getrennt (Zustand bleibt
unverändert).
- M1: nl_wifi_sm_onboard() trennt jetzt immer vor dem Testverbindungsaufbau,
damit veraltete Events eines vorherigen Versuchs nicht mehr zählen; der
Treiber (nl_wifi_request_onboard) meldet zusätzlich "wifi busy" (503),
solange bereits ein Onboarding-Test läuft.
Der Treiber (src/net/nl_wifi_w600.c) führt `disconnect` vor `connect` aus
und ruft nl_mdns_on_ip_up() nur noch, wenn der Zustand nach der Verarbeitung
tatsächlich STA_CONNECTED ist.
Tests (test_wifi_sm.c): Timeout/Fehler ohne Zugangsdaten -> disconnect;
CONNECTED während AP_ONBOARDING -> disconnect, Zustand unverändert;
erneutes Onboarding während laufendem Test -> disconnect vor dem neuen
connect.
C1: Ein POST an /api/v1/device/state mit tief verschachteltem JSON (z.B.
20 Ebenen "[[[[...") lässt cJSONs rekursiven Parser den 6-KB-Stack des
HTTP-Tasks überlaufen - CJSON_NESTING_LIMIT war mit 1000 viel zu hoch und
FreeRTOS' Stack-Overflow-Checking ist aus. Begrenzt auf 8 Ebenen, sowohl
für die Firmware (third_party/cJSON/cJSON.c) als auch für die
Host-Tests (nl_cjson), damit beide dasselbe Verhalten zeigen.
Test: test_post_validation deckt jetzt einen Body mit 20 verschachtelten
Arrays ab (400 "invalid json").