28 Commits
Author SHA1 Message Date
Michael SauerandClaude Opus 5 62a2b43175 docs: Migration zweier echter Lampen und den SM2135EH-Standby-Fund festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 02:37:08 +02:00
Michael SauerandClaude Opus 5 f32216d17f fix: SM2135EH beim Ausschalten in den Standby legen
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>
2026-09-13 02:32:31 +02:00
Michael SauerandClaude Sonnet 5 9768b25d10 docs: passwortloses sudo als Voraussetzung der Rettungsstation dokumentieren
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>
2026-09-13 01:30:03 +02:00
Michael SauerandClaude Sonnet 5 1d7d087cff fix: Rettungsstation loggt auch abgelehnte Methoden und Protokollfehler
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>
2026-09-13 01:29:56 +02:00
Michael SauerandClaude Sonnet 5 435884039b feat: ninlight-station warnt beim Einschalten vor dem oeffentlichen Default-Passwort
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>
2026-09-13 01:29:43 +02:00
Michael SauerandClaude Sonnet 5 2dfb1fd1c2 feat: GET system/settings zeigt Herkunft der WLAN-Zugangsdaten
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>
2026-09-13 01:29:34 +02:00
Michael SauerandClaude Sonnet 5 d464bb167e fix: Voll-Deploy der Rettungsstation gegen doppelte Images und stillen Loeschverlust absichern
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>
2026-09-13 01:29:25 +02:00
Michael SauerandClaude Opus 5 6daf36540c docs: Rettungsfall und Abschlusspruefung der Station belegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 01:05:11 +02:00
Michael SauerandClaude Opus 5 b169fae89d docs: Stillstandstest und Wiederaufnahme am EVK belegt
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:54:15 +02:00
Michael SauerandClaude Sonnet 5 3d69412f5a docs: Wiederaufnahme-Verhalten bei Firmware-Updates differenzieren
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>
2026-09-13 00:40:55 +02:00
Michael SauerandClaude Sonnet 5 3f27ef4b26 fix: --stall-after Budget der Rettungsstation client-kumulativ zählen
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>
2026-09-13 00:40:55 +02:00
Michael SauerandClaude Opus 5 2f25714248 docs: Abnahme der Pi-Station am EVK festhalten
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 00:32:22 +02:00
Michael SauerandClaude Sonnet 5 908fda8240 fix: OTA-Log im Recovery-Skip-Pfad nicht mehr als Fehlschlag ausgeben
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>
2026-09-13 00:29:12 +02:00
Michael SauerandClaude Sonnet 5 62903a7f05 fix: status.json fuer normale Nutzer lesbar machen, Lesefehler nicht mehr verschlucken
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>
2026-09-13 00:26:05 +02:00
Michael SauerandClaude Opus 5 cb54dac066 build: uv.lock der Station mit einchecken
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>
2026-09-12 13:17:49 +02:00
Michael SauerandClaude Sonnet 5 456058956c fix: setup_pi.sh und deploy.sh nutzen /usr/bin/ssh und /usr/bin/scp
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>
2026-09-12 13:13:15 +02:00
Michael SauerandClaude Sonnet 5 3032178247 fix: ninlight-station status gibt Dienstzustand nur einmal aus, Exit 0
"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>
2026-09-12 13:13:03 +02:00
Michael SauerandClaude Sonnet 5 4a8a5d658f fix: nl_mkimage wieder Python-3.8-tauglich machen
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>
2026-09-12 13:12:54 +02:00
Michael SauerandClaude Sonnet 5 d23b0d4e02 fix: Rechte-Bug und Passwort-Leck im NM-Profil, Loesch-Bug in deploy.sh
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>
2026-09-12 12:52:44 +02:00
Michael SauerandClaude Sonnet 5 fda732d6c4 feat: Aufsetz- und Ausliefer-Skripte der Recovery-Station
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>
2026-09-12 12:36:03 +02:00
Michael SauerandClaude Haiku 4.5 df8e796f1e fix: nl_mkimage Verifikation robuster gegen Edge Cases und Misuse
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>
2026-09-12 12:30:42 +02:00
Michael SauerandClaude Haiku 4.5 0ba269a39a feat: nl_mkimage prüft Images mit --verify
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>
2026-09-12 12:25:02 +02:00
Michael SauerandClaude Sonnet 5 cb0a4b035d fix: Startaufruf absichern und Neustartschleife der Station deckeln
- '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>
2026-09-12 12:21:26 +02:00
Michael SauerandClaude Sonnet 5 10cc67d490 fix: Fehlerbehandlung und Quoting der Recovery-Station nach Review
- '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>
2026-09-12 12:14:49 +02:00
Michael SauerandClaude Sonnet 5 d41c385375 feat: Konfiguration, systemd-Unit und CLI der Recovery-Station
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>
2026-09-12 12:02:32 +02:00
Michael SauerandClaude Sonnet 5 a2e87b8855 fix: Nebenläufigkeit und verfrühtes "ok" in der Recovery-Station beheben
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>
2026-09-12 11:58:18 +02:00
Michael SauerandClaude Sonnet 5 37f4dd425e feat: HTTP-Server der Recovery-Station
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>
2026-09-12 11:45:05 +02:00
Michael SauerandClaude Opus 5 76a3253322 docs: Recovery am EVK gegen die Pi-Station verifiziert
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:24:18 +02:00
22 changed files with 2088 additions and 23 deletions
+13 -5
View File
@@ -95,11 +95,19 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
Lampe und bootet mit der neuen Version. Wird das Image abgelehnt, gibt es **keinen** Reset — die Lampe läuft
mit der alten Firmware weiter und trägt den Grund in `last_ota_error` (`GET system/info`) ein. Ein
Verbindungsabbruch mitten im Download endet bei `last_ota_error = download`. Die Firmware kann einen
sauber abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt), aber
darauf sollte man sich nicht verlassen: Stirbt die Gegenstelle mitten im Download, hängt der Lesevorgang
am EVK gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout — das `RECV_TIMEOUT` von 5 s des
HTTP-Clients greift dort nicht — und bricht danach ohne Wiederaufnahme ab. Die Lampe bleibt in dieser Zeit
über REST erreichbar und lässt sich weiter schalten, weist aber jeden weiteren `fw_update` mit `409` ab.
abgerissenen Transfer per HTTP-Range wieder aufnehmen (auf eine feste Rundenzahl begrenzt) — was dabei
passiert, hängt aber davon ab, wie die Gegenstelle abbricht:
- **Gegenstelle bleibt erreichbar, liefert aber nichts mehr** (Station hängt, Netzwerkpfad lebt): Das
`RECV_TIMEOUT` von 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut
eine neue auf und setzt per `Range` genau dort fort, wo sie aufgehört hat. Am EVK gemessen funktionierte
das über **fünf** aufeinanderfolgende Reconnects hinweg, bis das Image vollständig übertragen war — auf
diesen Fall darf man sich also verlassen.
- **Gegenstelle stirbt komplett** (Prozess weg, nicht mehr erreichbar): Der Lesevorgang hängt am EVK
gemessen rund **sieben Minuten** in einem einzigen TCP-Timeout, den das 5-s-`RECV_TIMEOUT` der Firmware
nicht abfängt, und bricht danach **ohne** Wiederaufnahme ab. Das bleibt der unangenehme Fall: Die Lampe
ist während der sieben Minuten über REST erreichbar und lässt sich weiter schalten, weist aber jeden
weiteren `fw_update` mit `409` ab, bis der Download endgültig als gescheitert gilt.
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
+5
View File
@@ -225,6 +225,11 @@ items:
timeout: 0
followRedirects: true
maxRedirects: 5
docs: |-
`wifi.source` ist `"stored"`, solange ein per PUT onboardetes WLAN gespeichert ist,
sonst `"compiled"` - dann laeuft die Lampe auf den einkompilierten
NINLIGHT_WIFI_SSID/-PASSWORD, `wifi.ssid` ist leer, und `GET system/info` kann trotzdem
eine verbundene SSID zeigen.
- info:
name: Set WiFi
type: http
+29 -6
View File
@@ -44,7 +44,7 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
- [x] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset — 2026-09-12 (`crc`, kein Reset)
- [x] OTA: HTTP-Server während des Downloads abschalten → Lampe bleibt über REST erreichbar und schaltbar, weist weitere `fw_update` mit `409` ab, und beendet den Versuch mit `last_ota_error = download` — 2026-09-12 (dauerte ~7 min, kein Range-Resume; siehe README)
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler — *offen, WLAN am Teststandort nicht abschaltbar*
- [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen
- [x] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen — 2026-09-12 (Pi Zero 2W, NetworkManager-AP; Scan → join → fetch → flash → Reboot)
- [x] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen) — 2026-09-12 (`scan done after 1500 ms, 7 AP(s)`, danach normaler WLAN-Start)
- [ ] Recovery: Gegenstelle bleibt verbunden, sendet aber keine Daten mehr (nicht: ein langsamer, aber laufender Download) → nach Ablauf der Zeitobergrenze bedient die Firmware den Watchdog nicht mehr, Chip startet neu statt hängen zu bleiben
- [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand)
@@ -53,17 +53,17 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
- [x] Lichtverhalten über ein OTA hinweg (Spec §8.2, ein Update ist kein Stromausfall): einmal mit `power_on_behaviour = off`, einmal `= on` — die Lampe ist nach dem Update jeweils in demselben Zustand (an/aus, Farbe/Helligkeit), in dem sie vor dem Update war — 2026-09-12 (pob=off + Licht an → blieb an; pob=on + Licht aus → blieb aus)
- [x] `system/info.fw_version` nach einem OTA stimmt mit der Version im Trailer des geflashten Images überein (Trailer-Version z.B. mit `tools/mkimage` prüfen) — 2026-09-12
- [ ] Recovery im echten Rettungsfall: Lampe ohne gültige WLAN-Konfiguration bzw. im Onboarding-AP findet die Station, zieht das Image und startet mit der neuen Firmware
- [ ] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht)
- [x] Rettungsstation nach erfolgreicher Recovery angelassen lassen, Lampe neu booten lassen → kein erneutes Flashen (Version stimmt bereits überein) und kein Werksreset (Power-Cycle-Zähler wurde vor dem Recovery-Versuch gelöscht) — 2026-09-12 (zweiter Boot: `image already carries the running version, not flashing`, kein Werksreset, beide Boots melden `boot #1`)
- [ ] OTA im **AP-/Fallback-Modus** (`mode=apsta`, kein Router erreichbar) → Update läuft durch wie im normalen `sta`-Modus — *offen, braucht einen abschaltbaren Access Point*. Mit `auth_all = true` am 2026-09-12 geprüft: Update läuft durch, `device/*` antwortet ohne Token korrekt mit 401 (`system/info` bleibt laut Spec §7.2 immer offen)
Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in `config.local.cmake` setzen — die Defaults sind nur fürs EVK gedacht.
## Später (Lampen)
- [ ] SM2135EH: Standby-Bit bei allen Kanälen 0 prüfen (Spec §4; alte Firmware setzte es praktisch nie)
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß)
- [x] SM2135EH: Standby-Bit bei allen Kanälen 0 — 2026-09-13 an migrierter GU10 RGB geprüft: **war ein echter Fehler**, die Lampe glomm im Aus weiter. Bit 5 im Kanal-Register wird jetzt gesetzt (Commit zu v0.3.8); danach beide Ausschaltwege (aus Weiß und aus Farbe) vollständig dunkel
- [ ] E27 Color (SM2135EH): RGB und WY getrennt, Ströme plausibel (nicht zu heiß) — *derselbe Treiber ist am 2026-09-13 an einer GU10 RGB geprüft (rot/grün/blau sauber getrennt, warm- und kaltweiß deutlich unterschieden); für die E27 selbst steht die Prüfung noch aus*
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
- [ ] Farbtemperaturbereiche aller Varianten
- [x] G95: Pinbelegung WW=PB15/CW=PB13 — 2026-09-13 an einer migrierten G95 geprüft: 2500 K deutlich warm, 5000 K deutlich kühler, Dimmen auf 10 % flackerfrei, weiches Ausblenden
- [ ] Farbtemperaturbereiche aller Varianten — *g95_cw (2500–5000 K) und gu10_rgb (2700–6500 K) am 2026-09-13 an echten Lampen bestätigt; die übrigen sechs Varianten stehen aus*
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
@@ -73,3 +73,26 @@ unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läuf
Gerätetyp und bei den erfolgreichen Updates trat die Meldung nicht auf. Ursache noch nicht
geklärt — in einem Build ohne Assertions wäre das ein doppeltes Freigeben eines Netzwerkpuffers.
Vor dem Einsatz auf echten Lampen reproduzieren und aufklären.
## Recovery gegen die installierte Pi-Station (2026-09-13)
- [x] Station installiert per `tools/station/setup_pi.sh`, idempotent (zweiter Lauf ließ die Konfiguration in Ruhe) — 2026-09-13
- [x] `ninlight-station on` schaltet Zugangspunkt und Dienst gemeinsam, `status` meldet Dienst, Adresse, Image und Version, Exit 0 — 2026-09-13
- [x] Recovery zieht das Image und flasht: `recovery image version 0.3.7 (running 0.3.6)` → `trailer ok` → Neustart mit der neuen Version — 2026-09-13
- [x] Station angelassen, erneut gebootet → `already carries the running version 0.3.7, not flashing`, `ota: skipped`, kein Werksreset (beide Boots melden `boot #1`), `last_ota_error` bleibt leer — 2026-09-13
- [x] Zustandsdatei der Station protokolliert beide Auslieferungen mit `status: ok`, Gerätetyp und Version — 2026-09-13
- [x] Fehlendes Image für den Gerätetyp → Station antwortet `404`, Eintrag `notfound`, Lampe bootet normal weiter — 2026-09-13 (Lampe meldete `update failed (download)`)
- [x] Stillstand: Server hält die Verbindung, sendet nichts mehr → nach Ablauf der Zeitobergrenze startet der Chip neu — 2026-09-13 (Download 00:42:17, ~120 Wiederaufnahmeversuche im 5-s-Takt mit je null Bytes, Obergrenze exakt bei 600000 ms um 00:52:17, Reset um 00:52:56 nach dem 20-s-Watchdog, danach `boot #1` ohne Werksreset)
- [x] Wiederaufnahme nach Abriss: Gegenstelle erreichbar, sendet aber nichts mehr → Lampe setzt per `Range` fort — 2026-09-13 (fünf Fortsetzungen 263700 → 198164 → 132628 → 67092 → 1556, Image vollständig; gilt **nicht** für eine tote Gegenstelle, siehe README)
- [x] Echter Rettungsfall: Lampe ohne gültige WLAN-Konfiguration findet die Station und wird geflasht — 2026-09-13, zweistufig belegt: (1) Station aus, Build ohne Zugangsdaten seriell aufgespielt → `ninlight 0.3.7-4-gb169fa`, Scan ohne Treffer, Lampe geht in den Onboarding-AP, über Netz nicht erreichbar. (2) Station an, ein Stromzyklus → `station found` → `recovery image version 0.3.7 (running 0.3.7-4-gb169fa)` → `trailer ok` → Neustart → `connecting to ninIOT`. 23 Sekunden vom Einschalten bis zur geretteten Lampe.
- [x] `ninlight-station off`, Pi neu starten → Pi kommt im Heim-WLAN hoch, kein Zugangspunkt, Dienst inaktiv — 2026-09-13
**Bekannte Eigenheit:** Die Lampe lädt bei jedem Boot das komplette Image (~256 KB), auch wenn sie es danach
wegen gleicher Version verwirft — die Version steht im Trailer am Dateiende. Kein Fehler, aber die Station
sieht pro Boot einen vollen Transfer.
## Migration echter Lampen (Plan 4b, 2026-09-13)
- [x] G95 (Dev-Build, Product-ID `-klyqa.lighting.cw-ww.g95-dev`) mit dem **Entwicklungs**schlüssel signiert → angenommen, geflasht, läuft ninlight als `g95_cw`
- [x] GU10 (Produktionsbuild, Product-ID `-klyqa.lighting.rgb-cw-ww.gu10`) mit dem **Produktions**schlüssel signiert → angenommen, geflasht, läuft ninlight als `gu10_rgb`
- [x] Migrierte Lampe lässt sich anschließend über das normale ninlight-OTA aktualisieren (GU10 auf 0.3.8)
+10 -1
View File
@@ -7,6 +7,7 @@
#define SM_ADDR_CURRENT 0xC0u
#define SM_ADDR_WY 0xC5u
#define SM_CHANNEL_WY 0x80u /* channel select bit 7: 0 = RGB, 1 = WY */
#define SM_CHANNEL_STBY 0x20u /* standby bit 5: powers the outputs down */
extern void delay_us(unsigned int us); /* provided by libwlan.a */
@@ -76,7 +77,15 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT])
s_have_last = true;
if (!white) {
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, 0x00, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
/* All channels dark: put the driver into standby instead of just writing zero duty.
Without bit 5 the chip stays active and leaks enough current to keep the lamp
faintly glowing - measured on a migrated GU10 RGB, 2026-09-13. The old QConnex
firmware had this logic but left it commented out (qcl_lamp.c), which is why the
behaviour was inherited. Lamps driven straight from PWM pins (G95, the CW variants)
are unaffected. Turning back on writes 0x00 or SM_CHANNEL_WY here and clears it. */
bool dark = !ch[NL_CH_R] && !ch[NL_CH_G] && !ch[NL_CH_B];
uint8_t chan = dark ? SM_CHANNEL_STBY : 0x00u;
uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]};
sm_send(d, 6);
} else {
uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY};
+7
View File
@@ -262,6 +262,13 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp
cJSON_AddStringToObject(o, "name", s.name);
w = cJSON_AddObjectToObject(o, "wifi");
cJSON_AddStringToObject(w, "ssid", s.wifi_ssid);
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0);
reply_json(resp, 200, o);
}
+11 -2
View File
@@ -94,10 +94,19 @@ static void ota_task(void *arg)
(void)arg;
log_url(s_url);
rc = t_http_fwup(s_url);
/* Only reached on failure - the SDK resets the chip itself after a good image. */
if (s_error == NULL && !s_skipped)
/* Only reached on failure or a deliberate skip - the SDK resets the chip itself after a good
* image. rc is not a reliable success/failure signal here: the SDK aborts the transfer as
* soon as the trailer hook returns false, which happens both for a real rejection and for the
* deliberate "recovery image already up to date" skip. s_error/s_skipped are what actually
* distinguish the two, so branch on those instead of folding everything into one printf that
* would risk passing a NULL s_error to %s. */
if (s_skipped) {
printf("ota: skipped (rc=%d, image already up to date)\r\n", rc);
} else {
if (s_error == NULL)
s_error = "download";
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
}
{
nl_settings_t *s;
nl_app_lock();
+15
View File
@@ -233,6 +233,7 @@ static void test_settings_auth(void)
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name")));
TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
TEST_ASSERT_EQUAL_STRING("stored", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all")));
TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */
fake_open = true;
@@ -240,6 +241,19 @@ static void test_settings_auth(void)
TEST_ASSERT_EQUAL_INT(200, resp.status);
}
/* F1: nie onboardet -> gespeicherte ssid ist leer, die Lampe laeuft auf den
* einkompilierten Zugangsdaten (siehe app_main.c). "source" muss das additiv
* sichtbar machen, ohne "ssid" umzudeuten - sonst widerspricht die Antwort
* scheinbar system/info, das die tatsaechlich verbundene SSID zeigt. */
static void test_settings_wifi_source_reflects_compiled_fallback(void)
{
fake_settings.wifi_ssid[0] = '\0';
call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL);
TEST_ASSERT_EQUAL_INT(200, resp.status);
TEST_ASSERT_EQUAL_STRING("", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid")));
TEST_ASSERT_EQUAL_STRING("compiled", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "source")));
}
static void test_settings_wifi(void)
{
call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}");
@@ -356,6 +370,7 @@ int main(void)
RUN_TEST(test_info_is_open);
RUN_TEST(test_info_with_sta_ip_and_error);
RUN_TEST(test_settings_auth);
RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback);
RUN_TEST(test_settings_wifi);
RUN_TEST(test_settings_name_token_auth);
RUN_TEST(test_commands);
+73 -4
View File
@@ -4,7 +4,19 @@
Layout (512 bytes): "NINL" | version(1) | device[16] | fw_version[16] | sha256[32] | 0xFF padding.
The hash covers the complete `_gz.img` (T_BOOTER header + payload), which is exactly what the
firmware hashes while writing the image to flash.
Must run under Python 3.8.10: CMake invokes this script via plain `python3` inside the
x86_64 build container (see docker/Dockerfile, cmake/run_mkimage.cmake) on every firmware
build, and that container has not been upgraded past 3.8. This is independent of the
"Python >= 3.11" requirement elsewhere in this repo, which applies only to the tooling that
runs on the Mac and the Pi (tools/station, tools/mkimage's own pytest suite), never to this
file. Keep `from __future__ import annotations` below so that PEP 604 unions (`str | None`)
in annotations stay text and are never evaluated - without it, that syntax raises
`TypeError: unsupported operand type(s) for |: 'type' and 'NoneType'` on 3.8/3.9. See
test_nl_mkimage.py::test_stays_python38_compatible, which fails the build if this drifts.
"""
from __future__ import annotations
import argparse
import hashlib
import sys
@@ -19,18 +31,75 @@ def field(value: str, name: str) -> bytes:
raw = value.encode()
if len(raw) >= FIELD:
sys.exit(f"error: {name} '{value}' is too long (max {FIELD - 1} bytes)")
if any(c.isspace() for c in value):
sys.exit(f"error: {name} '{value}' contains whitespace")
return raw.ljust(FIELD, b"\x00")
def verify(path: Path, expect_device: str | None) -> int:
"""Checks an _ota.img the way the firmware does before accepting it."""
try:
data = path.read_bytes()
except OSError as e:
print(f"error: {e}", file=sys.stderr)
return 1
if len(data) <= TRAILER_SIZE:
print(f"error: {path} is too short to carry a trailer", file=sys.stderr)
return 1
payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:]
if trailer[0:4] != b"NINL":
print(f"error: {path} has no ninlight trailer", file=sys.stderr)
return 1
if trailer[4] != TRAILER_VERSION:
print(f"error: {path} has trailer version {trailer[4]}, expected {TRAILER_VERSION}",
file=sys.stderr)
return 1
# Device field must be null-terminated within its 16 bytes (same as firmware check)
device_field = trailer[5:21]
if b"\x00" not in device_field:
print(f"error: {path} has unterminated device field", file=sys.stderr)
return 1
device = device_field.rstrip(b"\x00").decode("ascii", "replace")
version = trailer[21:37].rstrip(b"\x00").decode("ascii", "replace")
if expect_device is not None and device != expect_device:
print(f"error: {path} carries device '{device}', expected '{expect_device}'",
file=sys.stderr)
return 1
if trailer[37:69] != hashlib.sha256(payload).digest():
print(f"error: {path} fails its sha256 - the payload does not match the trailer",
file=sys.stderr)
return 1
print(f"{device} {version} {len(data)}")
return 0
def main() -> None:
p = argparse.ArgumentParser(description="append the ninlight OTA trailer")
p.add_argument("--image", required=True, type=Path, help="input _gz.img")
p.add_argument("--device", required=True, help="device id, e.g. e27_rgb")
p.add_argument("--version", required=True, help="firmware version")
p.add_argument("--out", required=True, type=Path, help="output _ota.img")
p.add_argument("--image", type=Path, help="input _gz.img")
p.add_argument("--device", help="device id, e.g. e27_rgb")
p.add_argument("--version", help="firmware version")
p.add_argument("--out", type=Path, help="output _ota.img")
p.add_argument("--verify", type=Path, help="check an existing _ota.img instead of building one")
p.add_argument("--expect-device", help="with --verify: require this device id")
a = p.parse_args()
if a.verify is not None:
# Verify mode: check that build arguments are not provided
build_args = ("image", "device", "version", "out")
if any(getattr(a, name) is not None for name in build_args):
p.error("--verify cannot be combined with build mode arguments (--image, --device, --version, --out)")
raise SystemExit(verify(a.verify, a.expect_device))
# Build mode: require all arguments
for name in ("image", "device", "version", "out"):
if getattr(a, name) is None:
p.error(f"--{name} is required unless --verify is given")
try:
data = a.image.read_bytes()
except OSError as e:
sys.exit(f"error: {e}")
if len(data) > TRAILER_SIZE and data[-TRAILER_SIZE : -TRAILER_SIZE + 4] == b"NINL":
sys.exit("error: image already carries a trailer")
+1 -1
View File
@@ -4,4 +4,4 @@ version = "0.1.0"
requires-python = ">=3.11"
[dependency-groups]
dev = ["pytest>=8"]
dev = ["pytest>=8", "vermin>=1.6"]
+223
View File
@@ -53,3 +53,226 @@ def test_refuses_image_that_already_has_a_trailer(tmp_path):
)
assert r.returncode != 0
assert "trailer" in r.stderr
def verify(path, *extra):
return subprocess.run(
[sys.executable, str(SCRIPT), "--verify", str(path), *extra],
capture_output=True, text=True,
)
def test_verify_accepts_a_good_image(tmp_path):
_, out = build(tmp_path, device="e27_rgb", version="1.2.3")
r = verify(out)
assert r.returncode == 0, r.stderr
assert r.stdout.split()[:2] == ["e27_rgb", "1.2.3"]
def test_verify_rejects_a_file_without_trailer(tmp_path):
p = tmp_path / "plain.img"
p.write_bytes(b"x" * 5000)
r = verify(p)
assert r.returncode == 1
assert "trailer" in r.stderr.lower()
def test_verify_rejects_a_corrupted_payload(tmp_path):
_, out = build(tmp_path)
d = bytearray(out.read_bytes())
d[100] ^= 0x01
out.write_bytes(d)
r = verify(out)
assert r.returncode == 1
assert "sha256" in r.stderr.lower()
def test_verify_checks_the_expected_device(tmp_path):
_, out = build(tmp_path, device="evk")
assert verify(out, "--expect-device", "evk").returncode == 0
r = verify(out, "--expect-device", "g95_cw")
assert r.returncode == 1
assert "evk" in r.stderr and "g95_cw" in r.stderr
def test_verify_rejects_a_file_shorter_than_the_trailer(tmp_path):
p = tmp_path / "tiny.img"
p.write_bytes(b"NINL" + b"\x00" * 10)
r = verify(p)
assert r.returncode == 1
def test_verify_rejects_nonexistent_file(tmp_path):
p = tmp_path / "does_not_exist.img"
r = verify(p)
assert r.returncode == 1
assert r.stdout == ""
assert len(r.stderr.splitlines()) == 1
assert "error:" in r.stderr.lower()
def test_verify_rejects_directory(tmp_path):
d = tmp_path / "dir"
d.mkdir()
r = verify(d)
assert r.returncode == 1
assert r.stdout == ""
assert len(r.stderr.splitlines()) == 1
assert "error:" in r.stderr.lower()
def test_verify_rejects_empty_file(tmp_path):
p = tmp_path / "empty.img"
p.write_bytes(b"")
r = verify(p)
assert r.returncode == 1
assert "trailer" in r.stderr.lower()
def test_verify_rejects_file_with_only_trailer_no_payload(tmp_path):
p = tmp_path / "only_trailer.img"
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
trailer[5:21] = b"test\x00" + b"\x00" * 11
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
trailer[37:69] = hashlib.sha256(b"").digest()
p.write_bytes(bytes(trailer))
r = verify(p)
assert r.returncode == 1
assert "trailer" in r.stderr.lower()
def test_build_rejects_device_with_whitespace(tmp_path):
src = tmp_path / "fw_gz.img"
src.write_bytes(b"x")
r = subprocess.run(
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "dev ice",
"--version", "1.0", "--out", str(tmp_path / "o.img")],
capture_output=True, text=True,
)
assert r.returncode != 0
assert "whitespace" in r.stderr
def test_build_rejects_version_with_whitespace(tmp_path):
src = tmp_path / "fw_gz.img"
src.write_bytes(b"x")
r = subprocess.run(
[sys.executable, str(SCRIPT), "--image", str(src), "--device", "device",
"--version", "1.0 beta", "--out", str(tmp_path / "o.img")],
capture_output=True, text=True,
)
assert r.returncode != 0
assert "whitespace" in r.stderr
def test_verify_rejects_unterminated_device_field(tmp_path):
p = tmp_path / "bad_device.img"
payload = b"x" * 1000
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
# Fill entire device field with no null terminator
trailer[5:21] = b"a" * 16
trailer[21:37] = b"1.0.0\x00" + b"\x00" * 10
trailer[37:69] = hashlib.sha256(payload).digest()
p.write_bytes(payload + bytes(trailer))
r = verify(p)
assert r.returncode == 1
assert "unterminated" in r.stderr.lower()
def test_verify_rejects_unterminated_device_field_without_expect_device(tmp_path):
# Same as above but without --expect-device to ensure the check happens regardless
p = tmp_path / "bad_device2.img"
payload = b"x" * 1000
trailer = bytearray(b"\xff" * 512)
trailer[0:4] = b"NINL"
trailer[4] = 1
# Fill entire device field with no null terminator
trailer[5:21] = b"b" * 16
trailer[21:37] = b"2.0.0\x00" + b"\x00" * 10
trailer[37:69] = hashlib.sha256(payload).digest()
p.write_bytes(payload + bytes(trailer))
r = verify(p)
assert r.returncode == 1
assert "unterminated" in r.stderr.lower()
def test_verify_with_build_arguments_is_an_error(tmp_path):
_, out = build(tmp_path)
r = subprocess.run(
[sys.executable, str(SCRIPT), "--verify", str(out), "--device", "x"],
capture_output=True, text=True,
)
assert r.returncode != 0
assert "cannot be combined" in r.stderr
# --- Python 3.8 compatibility -------------------------------------------------------------
#
# CMake calls this script via plain `python3` inside the build container on every firmware
# build (see docker/Dockerfile, cmake/run_mkimage.cmake), and that container is stuck on
# Python 3.8.10. This is the opposite constraint from the rest of the repo's tooling (which
# needs Python >= 3.11) - see the module docstring in nl_mkimage.py for the full story, and
# git history around the "unsupported operand type(s) for |" break for what happens when it
# regresses.
#
# Two checks, because neither alone covers the failure mode we actually hit:
# 1. `from __future__ import annotations` must be the first statement after the module
# docstring. As long as it's there, PEP 604 unions in annotations (`str | None`) are
# never evaluated at runtime, so they can't raise on 3.8/3.9 no matter how many more of
# them get added later.
# 2. vermin's *default* analysis (annotations not evaluated - matching point 1) catches
# everything else 3.9+/3.10+/3.11+ that could sneak in: walrus in odd positions, match
# statements, dict `|=`, new stdlib members, etc. We deliberately do NOT enable vermin's
# `union-types` feature: it flags `str | None` as needing 3.10 unconditionally, without
# accounting for `from __future__ import annotations` deferring evaluation - which would
# make this test fail on the very fix that makes the file 3.8-safe. Check 1 above is
# what actually guards against that pattern.
def test_future_annotations_import_is_present():
"""Guards the PEP 604 union pattern that broke the Docker build (see module docstring)."""
import ast
tree = ast.parse(SCRIPT.read_text())
body = tree.body
# Skip a leading module docstring, if any.
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \
and isinstance(body[0].value.value, str):
body = body[1:]
assert body, "nl_mkimage.py is unexpectedly empty"
first = body[0]
assert isinstance(first, ast.ImportFrom) and first.module == "__future__" \
and any(alias.name == "annotations" for alias in first.names), (
"nl_mkimage.py must start with `from __future__ import annotations` right after its "
"module docstring - without it, PEP 604 unions like `str | None` in annotations are "
"evaluated eagerly and crash under the Python 3.8.10 in the Docker build container "
"(TypeError: unsupported operand type(s) for |: 'type' and 'NoneType')"
)
def test_stays_python38_compatible():
"""Static check that nl_mkimage.py needs nothing newer than Python 3.8 to run.
Runs without a Python 3.8 interpreter installed: vermin infers the minimum required
version from syntax/stdlib usage instead of actually executing the file.
"""
vermin = __import__("vermin")
config = vermin.Config()
config.set_quiet(True)
# Match nl_mkimage.py's own strategy: with `from __future__ import annotations` present
# (checked separately above), annotations are never evaluated, so vermin shouldn't assume
# they need runtime support either. This is vermin's default, spelled out here so it's
# obvious why `union-types` (see the block comment above) is intentionally not enabled.
config.set_eval_annotations(False)
source = SCRIPT.read_text()
py2_min, py3_min = vermin.detect(source, config, str(SCRIPT))
assert py3_min is not None and py3_min <= (3, 8), (
f"nl_mkimage.py now needs Python {py3_min} - it must keep working on the "
"Python 3.8.10 in the Docker build container (see module docstring)"
)
+14 -1
View File
@@ -28,12 +28,16 @@ source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
{ name = "vermin" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
dev = [
{ name = "pytest", specifier = ">=8" },
{ name = "vermin", specifier = ">=1.6" },
]
[[package]]
name = "packaging"
@@ -77,3 +81,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "vermin"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/7b/2c1b403f2a844e1acb36694fc336e323df742f7f752edb4188311ad43f9e/vermin-1.8.0.tar.gz", hash = "sha256:3621955ac2a2950175c5b4a9b2fc3bd24bd416da0388893c9eb6971264e4ca1f", size = 99913, upload-time = "2025-11-20T17:29:31.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/fd/6d2167767488e162efd01300a679c7f7d913dbb65baa0b15760ebb369854/vermin-1.8.0-py3-none-any.whl", hash = "sha256:6f2b98ad697a16ed3442e81a0f0baabfc38c185b78aaab1b1fecd87bdd6640b8", size = 98030, upload-time = "2025-11-20T17:29:29.931Z" },
]
+48
View File
@@ -0,0 +1,48 @@
# ninlight-Recovery-Station
Ein Raspberry Pi spannt den Zugangspunkt `ninlight-recovery` auf und liefert unter
`http://192.168.8.1/ninlight_<target>_ota.img` Firmware-Images aus.
Jede ninlight-Lampe sucht beim Booten bis zu drei Sekunden nach dieser Station. Findet sie
sie, lädt sie das Image für ihren Gerätetyp und flasht es — ohne dass die Lampe im WLAN
erreichbar sein muss. Das ist der Rettungsweg für Lampen, an die man sonst nur noch mit dem
Lötkolben käme.
Trägt das angebotene Image dieselbe Version, die bereits läuft, lehnt die Lampe es ab und
bootet normal weiter. Eine angelassene Station flasht also nicht in einer Endlosschleife.
## Voraussetzung
Der SSH-Benutzer auf dem Pi braucht passwortloses `sudo` — beide Skripte und die CLI
(`ninlight-station on|off|status`) rufen `sudo` nicht-interaktiv auf; ein Passwort-Prompt
würde dort nur mit einer Fehlermeldung enden.
## Bedienung
tools/station/setup_pi.sh <pi-host> einmalig einrichten
tools/station/deploy.sh <pi-host> [target] Images ausliefern
ssh <pi-host> ninlight-station on einschalten
ssh <pi-host> ninlight-station status Zustand und letzte Auslieferungen
ssh <pi-host> ninlight-station off ausschalten
SSID und Passwort stehen in `/etc/ninlight-station.conf` auf dem Pi und müssen zu den
Build-Parametern `NINLIGHT_RECOVERY_SSID` und `NINLIGHT_RECOVERY_PASSWORD` aus
`config.local.cmake` passen. Weicht eines ab, findet die Lampe die Station nicht.
## Vertrauensgrenze
Der Trailer eines Images prüft Gerätetyp und SHA256. Er ist **keine** kryptografische
Signatur, und der Download läuft über `http://` ohne TLS. Die Station schützt allein ein
WPA2-Passwort, dessen Default im Repo steht. Wer in Funkreichweite ist und es kennt, kann
jeder Lampe beim nächsten Boot beliebige Firmware unterschieben.
Das ist gewollt: Die Signaturprüfung der alten Firmware auszuhebeln ist der Zweck dieses
Projekts. Daraus folgen zwei Regeln.
**Setz ein eigenes Passwort.** In `config.local.cmake` und in
`/etc/ninlight-station.conf` — an beiden Stellen denselben Wert.
**Lass die Station nur an, solange du sie brauchst.** Nicht nur wegen des Fremdzugriffs:
Die Firmware löscht den Power-Cycle-Zähler, bevor sie einen Recovery-Versuch startet.
Solange eine Station in Reichweite ist, funktioniert der Werksreset per fünf Stromzyklen
bei **keiner** Lampe in Reichweite mehr.
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Liefert Firmware-Images und den Stationscode an den Pi aus.
#
# Aufruf: tools/station/deploy.sh <pi-host> [target ...]
# Ohne target-Argumente werden alle in build/*/ gefundenen _ota.img uebertragen.
set -euo pipefail
HOST=${1:?Aufruf: tools/station/deploy.sh <pi-host> [target ...]}
shift || true
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/../.." && pwd)
MKIMAGE="$ROOT/tools/mkimage/nl_mkimage.py"
# /usr/bin/ssh statt einem ssh im PATH, das sich anders verhaelt (siehe A3-Notiz im
# Abnahmebericht). Ueberschreibbar, damit tests/test_deploy.sh gegen einen Stub statt
# einen echten Pi laufen kann - dasselbe Prinzip wie bei NINLIGHT_STATION_ROOT unten.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
declare -a FILES=()
if [ $# -gt 0 ]; then
# Gezielter Deploy: nur die genannten Geraetetypen werden ersetzt, alle
# anderen Images im Zielverzeichnis bleiben unangetastet (siehe FULL_SYNC
# unten).
FULL_SYNC=0
for t in "$@"; do
f="$ROOT/build/$t/ninlight_${t}_ota.img"
[ -f "$f" ] || { echo "Fehler: $f fehlt - erst bauen" >&2; exit 1; }
FILES+=("$f")
done
else
FULL_SYNC=1
# Zwei Build-Verzeichnisse koennen denselben Image-Dateinamen liefern (z.B.
# build/evk/ und ein Nebenschauplatz wie build/evk-onb/, das ohne WLAN-
# Zugangsdaten gebaut wurde). Landen beide flach im selben STAGE/$base,
# entscheidet allein die Sortierreihenfolge, welches gewinnt - und die
# Trailer-Pruefung merkt nichts, weil in beiden derselbe Geraetetyp steht.
# Ein zweiter Fundort fuer denselben Dateinamen ist deshalb ein Abbruch,
# kein Zufall.
declare -A SEEN_AT=()
while IFS= read -r f; do
base=$(basename "$f")
if [ -n "${SEEN_AT[$base]:-}" ]; then
echo "Fehler: $base mehrfach gefunden - ${SEEN_AT[$base]} und $f. Voller Deploy abgebrochen, welches Bild echt ist, ist nicht entscheidbar. Nicht benoetigtes Build-Verzeichnis loeschen oder gezielt deployen." >&2
exit 1
fi
SEEN_AT[$base]=$f
FILES+=("$f")
done < <(find "$ROOT/build" -maxdepth 2 -name 'ninlight_*_ota.img' | sort)
[ ${#FILES[@]} -gt 0 ] || { echo "Fehler: keine Images unter $ROOT/build gefunden" >&2; exit 1; }
fi
echo "== Images pruefen =="
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
printf '%-26s %-10s %s\n' GERAET VERSION BYTES
for f in "${FILES[@]}"; do
base=$(basename "$f")
target=${base#ninlight_}; target=${target%_ota.img}
# Ein Image, das die Pruefung nicht besteht, wuerde jede Lampe ablehnen -
# das faellt sonst erst am Geraet auf.
out=$(python3 "$MKIMAGE" --verify "$f" --expect-device "$target") || {
echo "Fehler: $base besteht die Trailer-Pruefung nicht, nichts uebertragen" >&2; exit 1; }
# shellcheck disable=SC2086
set -- $out
printf '%-26s %-10s %s\n' "$1" "$2" "$3"
cp "$f" "$STAGE/$base"
done
echo "== uebertragen =="
# -c (Inhaltsvergleich per Checksumme statt Groesse+Zeitstempel): ein neues
# Image kann zufaellig dieselbe Byte-Groesse wie das alte haben, und bei
# schnell aufeinanderfolgenden Deploys reicht rsyncs Sekundenaufloesung fuer
# den Zeitstempel nicht, um das als Aenderung zu erkennen. Ohne -c wuerde
# rsync ein solches Update dann stillschweigend ueberspringen.
#
# /tmp/ninlight-images auf dem Pi ist nur eine fluechtige Ablage fuer diesen
# Lauf - hier darf --delete unbedingt ran, das betrifft nicht das live
# ausgelieferte Verzeichnis.
# -e: rsync soll fuer den Transport zum Pi denselben ssh wie oben benutzen.
rsync -a -c --delete -e "$SSH_BIN" "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a -e "$SSH_BIN" "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
# Pfade als Variablen mit Default: so kann ein Test die Installation gegen
# ein Sandbox-Verzeichnis statt gegen echte Systempfade laufen lassen.
"$SSH_BIN" "$HOST" bash -s -- "$FULL_SYNC" <<'REMOTE'
set -e
FULL_SYNC=$1
ROOT_DIR=${NINLIGHT_STATION_ROOT:-/opt/ninlight-station}
BIN_PATH=${NINLIGHT_STATION_BIN:-/usr/local/bin/ninlight-station}
UNIT_PATH=${NINLIGHT_STATION_UNIT:-/etc/systemd/system/ninlight-station.service}
sudo install -d -m 0755 "$ROOT_DIR/images"
if [ "$FULL_SYNC" -eq 1 ]; then
# Vollabgleich: alle Images ausserhalb der aktuellen Auslieferung entfernen.
# Vorher zeigen, WELCHE das sind - sonst faellt ein geloeschtes Image erst
# auf, wenn jemand mit einer toten Lampe vor der Station steht. Ermittelt
# per einfachem Namensvergleich (portabel, kein Verlass auf rsyncs
# "deleting"-Ausgabe, die ohne -v gar nicht erscheint).
OLD_LIST=$(find "$ROOT_DIR/images" -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; 2>/dev/null | sort)
NEW_LIST=$(find /tmp/ninlight-images -maxdepth 1 -name 'ninlight_*_ota.img' -exec basename {} \; | sort)
REMOVED=""
if [ -n "$OLD_LIST" ]; then
REMOVED=$(comm -23 <(printf '%s\n' "$OLD_LIST") <(printf '%s\n' "$NEW_LIST"))
fi
if [ -n "$REMOVED" ]; then
echo " voller Abgleich - folgende Images werden entfernt:"
echo "$REMOVED" | sed 's/^/ /'
else
echo " voller Abgleich - keine Images werden entfernt"
fi
sudo rsync -a -c --delete /tmp/ninlight-images/ "$ROOT_DIR/images/"
else
# Gezielter Deploy: nur die uebertragenen Dateien ersetzen/ergaenzen, alle
# anderen Geraetetypen im Zielverzeichnis bleiben erhalten (kein --delete).
echo " gezielter Deploy - andere Geraetetypen im Zielverzeichnis bleiben erhalten"
sudo rsync -a -c /tmp/ninlight-images/ "$ROOT_DIR/images/"
fi
sudo install -m 0755 /tmp/server.py "$ROOT_DIR/server.py"
sudo install -m 0755 /tmp/ninlight-station "$BIN_PATH"
sudo install -m 0644 /tmp/ninlight-station.service "$UNIT_PATH"
rm -rf /tmp/ninlight-images /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
sudo systemctl daemon-reload
if systemctl is-active --quiet ninlight-station; then
if sudo systemctl restart ninlight-station; then
echo " Dienst neu gestartet"
else
echo " Fehler: Dienst lief, Neustart nach dem Deploy ist aber fehlgeschlagen" >&2
exit 1
fi
else
echo " Dienst laeuft nicht (Station ist aus)"
fi
REMOTE
echo "fertig."
+142
View File
@@ -0,0 +1,142 @@
#!/bin/sh
# Schaltet die ninlight-Recovery-Station an und aus.
#
# Die Station ist absichtlich nicht dauerhaft an: Solange sie laeuft, kann jeder
# in Funkreichweite mit dem Passwort einer Lampe beim naechsten Boot beliebige
# Firmware unterschieben, und der Werksreset per fuenf Stromzyklen funktioniert
# bei keiner Lampe in Reichweite mehr (die Firmware loescht den Zaehler vor
# jedem Recovery-Versuch).
set -eu
CONF=/etc/ninlight-station.conf
if [ -r "$CONF" ]; then
# shellcheck disable=SC1090
. "$CONF"
elif [ -e "$CONF" ]; then
# $CONF ist 0600 root:root, damit das Passwort nicht fuer jeden lesbar
# ist. Der dokumentierte Aufruf "ssh <pi-host> ninlight-station on" laeuft
# aber als normaler Benutzer - "sudo cat" liest die Datei ein, ohne ihre
# Rechte aufzuweichen.
eval "$(sudo cat "$CONF")"
else
echo "Fehler: $CONF fehlt - zuerst tools/station/setup_pi.sh ausfuehren" >&2
exit 1
fi
for _key in SSID PSK IMAGE_DIR HOME_CONNECTION AP_CONNECTION; do
eval "test -n \"\${$_key-}\"" || {
echo "Fehler: $CONF: Schluessel $_key fehlt oder ist leer - tools/station/setup_pi.sh ausfuehren" >&2
exit 1
}
done
unset _key
need_profile() {
nmcli -t -f NAME con show | grep -qx "$AP_CONNECTION" || {
echo "Fehler: NetworkManager-Profil '$AP_CONNECTION' fehlt - tools/station/setup_pi.sh ausfuehren" >&2
exit 1
}
}
images() {
find "$IMAGE_DIR" -maxdepth 1 -name 'ninlight_*_ota.img' 2>/dev/null | sort
}
case "${1:-}" in
on)
need_profile
n=$(images | wc -l | tr -d ' ')
[ "$n" -gt 0 ] || echo "Warnung: $IMAGE_DIR enthaelt kein Image - die Station antwortet auf alles mit 404" >&2
sudo nmcli con up "$AP_CONNECTION" >/dev/null
# Eine fruehere Absturzserie darf den naechsten ehrlichen Versuch nicht
# blockieren (systemd verweigert "start" sonst sofort mit "start-limit-hit").
sudo systemctl reset-failed ninlight-station 2>/dev/null || true
if ! sudo systemctl start ninlight-station; then
# Der Start-Aufruf selbst ist gescheitert (z.B. start-limit-hit) - unter
# set -eu wuerde das Skript hier sonst abbrechen, bevor die
# Lebendigkeitspruefung unten je erreicht wird, und der eben
# hochgefahrene Zugangspunkt bliebe offen ohne Server dahinter stehen.
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
echo "Fehler: ninlight-station liess sich nicht starten - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
exit 1
fi
# "systemctl start" meldet bei Type=simple schon Erfolg, sobald der Prozess
# gestartet wurde - nicht, sobald er ueberlebt hat. Kurz warten und wirklich
# nachsehen, sonst bleibt ein offener AP ohne Server dahinter stehen.
sleep 1
if systemctl is-active --quiet ninlight-station; then
echo "Station an: SSID $SSID auf 192.168.8.1, $n Image(s)"
# Mit dem oeffentlichen Default-Passwort (im Repo dokumentiert) kann jeder in
# Funkreichweite, der es kennt, jeder Lampe beliebige Firmware unterschieben - das ist
# die eine Haelfte der Betriebsregeln aus README.md/§9, die andere (Station wieder
# ausschalten) stand hier schon.
if [ "$PSK" = "ninlight-recovery" ]; then
echo "Warnung: PSK ist noch der oeffentliche Default 'ninlight-recovery' - eigenes Passwort in /etc/ninlight-station.conf setzen." >&2
fi
echo "Nicht vergessen: 'ninlight-station off', wenn du fertig bist."
else
# Mit Restart=always laeuft ein abgestuerzter Dienst sonst im Hintergrund
# weiter in seiner Neustartschleife - erst stoppen, dann den AP runter,
# sonst suggeriert die Meldung unten einen saubereren Zustand als er ist.
sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
echo "Fehler: ninlight-station ist nach dem Start nicht aktiv - Zugangspunkt wieder abgeschaltet. Details: journalctl -u ninlight-station" >&2
exit 1
fi
;;
off)
sudo systemctl stop ninlight-station 2>/dev/null || true
sudo nmcli con down "$AP_CONNECTION" >/dev/null 2>&1 || true
if nmcli -t -f NAME con show | grep -qx "$HOME_CONNECTION"; then
if sudo nmcli con up "$HOME_CONNECTION" >/dev/null; then
echo "Station aus, wlan0 zurueck im Heim-WLAN ($HOME_CONNECTION)"
else
echo "Station aus. Zurueckschalten zu '$HOME_CONNECTION' fehlgeschlagen - wlan0-Status pruefen." >&2
fi
else
echo "Station aus. Profil '$HOME_CONNECTION' fehlt, wlan0 bleibt ungenutzt - der Zugang ueber Kabel ist davon nicht betroffen." >&2
fi
;;
status)
# "systemctl is-active" schreibt den Zustand ("inactive", "failed", ...) auf stdout und
# liefert dabei einen Rueckgabewert ungleich 0, sobald der Dienst nicht "active" ist -
# ein "|| echo inaktiv" wuerde also zusaetzlich zur ersten Zeile ausgeloest und beide
# landen in der Ausgabe. Der Zustand selbst ist die Auskunft; "status" ist damit
# erfolgreich, sobald es ihn ermitteln konnte, unabhaengig vom Dienstzustand - deshalb
# am Ende explizit exit 0.
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || true)"
printf 'wlan0 : %s\n' "$(nmcli -t -f GENERAL.CONNECTION device show wlan0 2>/dev/null | cut -d: -f2)"
printf 'Adresse: %s\n' "$(ip -4 -br addr show wlan0 2>/dev/null | awk '{print $3}')"
echo "Images :"
images | while read -r f; do
printf ' %-34s %s\n' "$(basename "$f")" \
"$(python3 - "$f" <<'PY'
import pathlib, sys
t = pathlib.Path(sys.argv[1]).read_bytes()[-512:]
print(t[21:37].rstrip(b"\x00").decode("ascii", "replace") if t[:4] == b"NINL" else "kein Trailer")
PY
)"
done
# "status" soll mit 0 enden, sobald es seine Auskunft geben konnte, auch wenn noch nie
# ausgeliefert wurde - ohne das "|| true" waere der Exit-Code des Skripts hier der des
# fehlgeschlagenen Tests (1), weil das die letzte Anweisung im Zweig ist.
#
# "existiert nicht" (Station lief noch nie) und "existiert, ist aber nicht lesbar"
# sind zwei verschiedene Zustaende und duerfen nicht beide stillschweigend im
# "-r"-Test verschwinden: sonst faellt ein Rechte-Fehler auf status.json (siehe
# server.py, schreibt als root) nie auf, weil die Ausgabe genauso aussieht wie
# "noch nie ausgeliefert".
STATUS_FILE=/run/ninlight-station/status.json
if [ -r "$STATUS_FILE" ]; then
echo "Zuletzt ausgeliefert:"
sed 's/^/ /' "$STATUS_FILE"
elif [ -e "$STATUS_FILE" ]; then
echo "Fehler: $STATUS_FILE existiert, ist aber nicht lesbar (Rechte pruefen)" >&2
fi
exit 0
;;
*)
echo "Aufruf: ninlight-station on|off|status" >&2
exit 2
;;
esac
+40
View File
@@ -0,0 +1,40 @@
[Unit]
Description=ninlight recovery station (HTTP image server)
# Ohne den Zugangspunkt hat der Server beim Start keine Adresse zum Binden.
# Bewusst kein BindsTo=NetworkManager.service: ein dadurch ausgeloester Stop
# gilt fuer systemd als sauber und wuerde von KEINER Restart=-Politik erneut
# gestartet - ein NetworkManager-Neustart wuerde den Server dann still und
# dauerhaft mitreissen. Stattdessen nur Start-Reihenfolge vorgeben und den
# Prozess bei jedem Absturz (z.B. Bind-Fehler nach einem NM-Neustart) per
# Restart=always in kurzen Abstaenden neu versuchen lassen.
After=network.target NetworkManager.service
# Steht der Zugangspunkt nicht, kann server.py 192.168.8.1 nicht binden und
# stirbt sofort; Restart=always versucht es alle RestartSec=2 erneut. Damit
# das nicht endlos das Journal flutet, sondern nach kurzer Zeit ehrlich
# aufgibt (und ninlight-station dann mit einer klaren Fehlermeldung reagieren
# kann statt in einer stillen Neustartschleife zu haengen), hier explizit
# gedeckelt statt sich auf systemds Vorgabewerte zu verlassen.
StartLimitIntervalSec=30
StartLimitBurst=5
[Service]
Type=simple
EnvironmentFile=/etc/ninlight-station.conf
ExecStart=/usr/bin/python3 /opt/ninlight-station/server.py --image-dir ${IMAGE_DIR}
Restart=always
RestartSec=2
# Laeuft als root: Port 80 und - fuer 4c - die DHCP-Leases in
# /var/lib/NetworkManager (Verzeichnis 0700 root:root) sind sonst nicht erreichbar.
# Gegengewicht:
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
RuntimeDirectory=ninlight-station
ReadWritePaths=/run/ninlight-station
RestrictAddressFamilies=AF_INET AF_UNIX
MemoryMax=128M
[Install]
WantedBy=multi-user.target
+8
View File
@@ -0,0 +1,8 @@
[project]
name = "ninlight-station"
version = "0.1.0"
description = "recovery station HTTP server for ninlight lamps"
requires-python = ">=3.11"
[dependency-groups]
dev = ["pytest>=8"]
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""ninlight recovery station.
Serves firmware images to lamps that look for a rescue station while booting. Deliberately
minimal: it hands out nothing but files whose name matches ninlight_<target>_ota.img, so a
stray file in the image directory can never leave the machine.
Standard library only - the Pi gets no extra packages.
"""
from __future__ import annotations
import argparse
import contextlib
import json
import os
import re
import select
import socket
import sys
import tempfile
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw")
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
TRAILER_SIZE = 512
TRAILER_MAGIC = b"NINL"
VERSION_OFF = 21
VERSION_LEN = 16
RECENT_MAX = 8
CHUNK = 32 * 1024
def now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def trailer_version(path: Path) -> str:
"""Firmware version from the image trailer, or '?' if the file carries none."""
try:
with path.open("rb") as f:
if path.stat().st_size < TRAILER_SIZE:
return "?"
f.seek(-TRAILER_SIZE, os.SEEK_END)
tail = f.read(TRAILER_SIZE)
except OSError:
return "?"
if tail[0:4] != TRAILER_MAGIC:
return "?"
return tail[VERSION_OFF:VERSION_OFF + VERSION_LEN].rstrip(b"\x00").decode("ascii", "replace")
class Station:
def __init__(self, image_dir: Path, state_path: Path, stall_after: int | None) -> None:
self.image_dir = image_dir
self.state_path = state_path
self.stall_after = stall_after
self.since = now_iso()
self.served = 0
self.recent: list[dict] = []
# Cumulative bytes already delivered to each client while --stall-after is active,
# keyed by client IP. A lamp whose download stalls re-opens a fresh connection and
# resumes with Range right where it left off, so a per-request counter would hand
# every reconnect a brand-new --stall-after allowance and the stall would never
# actually stick (see reserve_stall_bytes below).
self.stall_sent: dict[str, int] = {}
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
# can be downloading at once. Every mutation of served/recent/stall_sent and the
# snapshot that gets serialized to disk must happen under this same lock, or
# concurrent requests lose increments and stomp on each other's status.json writes.
self.lock = threading.RLock()
self.write_state()
def image_path(self, target: str) -> Path:
return self.image_dir / f"ninlight_{target}_ota.img"
def reserve_stall_bytes(self, ip: str, n: int) -> int:
"""How many of the next `n` bytes this client may still receive under --stall-after.
Tracks bytes cumulatively per client IP, across requests, so a stalled connection
that the lamp abandons and resumes via Range does not get a fresh budget just
because it is a new TCP connection. Only called when stall_after is set.
"""
with self.lock:
sent_so_far = self.stall_sent.get(ip, 0)
allowed = max(0, min(n, self.stall_after - sent_so_far))
self.stall_sent[ip] = sent_so_far + allowed
return allowed
def record(self, ip: str, target: str, version: str, nbytes: int, status: str) -> None:
with self.lock:
if status == "ok":
self.served += 1
self.recent.append({"ts": now_iso(), "ip": ip, "target": target,
"version": version, "bytes": nbytes, "status": status})
del self.recent[:-RECENT_MAX]
self.write_state()
def write_state(self) -> None:
with self.lock:
payload = {"since": self.since, "served": self.served, "recent": list(self.recent)}
try:
self.state_path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=self.state_path.parent, prefix=".status-")
try:
with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2)
# The service runs as root and mkstemp() creates the file 0600, so
# without this an operator reading status.json as a normal user (see
# tools/station/ninlight-station) gets "permission denied" - and since
# os.replace() preserves the mode it arrives with, that wrong mode
# would otherwise survive onto the live file on every single write,
# not just the first. os.chmod() ignores the process umask (unlike the
# mode argument to mkstemp/open), so a restrictive umask on the Pi
# can't quietly bring the 0600 back. Set it before the rename so
# there's never a window where the path holds a 0600 file.
os.chmod(tmp, 0o644)
os.replace(tmp, self.state_path)
except BaseException:
with contextlib.suppress(OSError):
os.unlink(tmp)
raise
except OSError as e:
print(f"warning: cannot write {self.state_path}: {e}", flush=True)
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "ninlight-station"
@property
def station(self) -> Station:
return self.server.station # type: ignore[attr-defined]
def log_message(self, fmt: str, *args) -> None: # noqa: D102 - quieter default log
pass
def log_error(self, fmt: str, *args) -> None:
# BaseHTTPRequestHandler.log_error() calls log_message() by default, which we
# silence above in favour of our own log() calls - but that also silences it for
# requests that never reach do_GET/do_POST (a malformed request line, a client
# timeout mid-request). The station is the only diagnostic tool available during a
# rescue, so those cases must still produce a line instead of nothing.
try:
client = self.client_address[0]
except (AttributeError, IndexError):
client = "?"
print(f"{now_iso()} {client} ERROR {fmt % args}", flush=True)
def log(self, status: int, nbytes: int, started: float) -> None:
print(f"{now_iso()} {self.client_address[0]} {self.path} {status} "
f"{nbytes}B {time.monotonic() - started:.1f}s", flush=True)
def do_POST(self) -> None:
started = time.monotonic()
self.reject(405, "method not allowed")
self.log(405, 0, started)
do_PUT = do_DELETE = do_HEAD = do_POST
def reject(self, status: int, text: str) -> None:
body = (text + "\n").encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(body)
def do_GET(self) -> None:
started = time.monotonic()
m = PATH_RE.match(self.path)
if not m:
self.reject(404, "not found")
self.log(404, 0, started)
return
target = m.group(1)
path = self.station.image_path(target)
if not path.is_file():
# Record before responding: a client that sees the 404 complete may check
# status.json right away, so the entry must already be on disk by then.
self.station.record(self.client_address[0], target, "?", 0, "notfound")
self.reject(404, "no image for this device")
self.log(404, 0, started)
return
size = path.stat().st_size
start = 0
rng = self.headers.get("Range", "")
if rng.startswith("bytes="):
first = rng.split("=", 1)[1].split("-", 1)[0]
if first.isdigit() and int(first) < size:
start = int(first)
version = trailer_version(path)
self.send_response(206 if start else 200)
self.send_header("Content-Type", "application/octet-stream")
self.send_header("Content-Length", str(size - start))
if start:
self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
self.send_header("Accept-Ranges", "bytes")
self.send_header("Connection", "close")
self.end_headers()
sent = 0
status = "ok"
remaining = size - start
client_ip = self.client_address[0]
try:
with path.open("rb") as f:
f.seek(start)
while sent < remaining:
read_size = min(CHUNK, remaining - sent)
if self.station.stall_after is not None:
# Reserve against this client's cumulative budget, not just this
# request's byte count - see Station.reserve_stall_bytes.
read_size = self.station.reserve_stall_bytes(client_ip, read_size)
if read_size == 0:
# test aid: hold the connection open and send nothing more, so
# the firmware's stall handling can be exercised on real
# hardware. We still notice if the peer gives up and closes its
# side, so a test (or an impatient client) that hangs up is
# recorded as "aborted" instead of leaking the thread forever.
while True:
readable, _, _ = select.select([self.connection], [], [], 1.0)
if readable:
try:
peek = self.connection.recv(1, socket.MSG_PEEK)
except OSError:
peek = b""
if not peek:
raise ConnectionResetError("peer closed during stall")
chunk = f.read(read_size)
if not chunk:
break
self.wfile.write(chunk)
sent += len(chunk)
except (BrokenPipeError, ConnectionResetError, TimeoutError):
status = "aborted"
# Record only now that the transfer has actually finished (or failed): "ok" must
# never be written before the last byte is confirmed sent, or status.json can
# claim success for a device that never received the end of the image.
self.log(206 if start else 200, sent, started)
self.station.record(self.client_address[0], target, version, sent, status)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description="ninlight recovery station HTTP server")
p.add_argument("--image-dir", required=True, type=Path)
p.add_argument("--bind", default="192.168.8.1")
p.add_argument("--port", type=int, default=80)
p.add_argument("--state", type=Path, default=Path("/run/ninlight-station/status.json"))
p.add_argument("--stall-after", type=int, default=None,
help="test aid: stop sending after N bytes but keep the connection open")
a = p.parse_args(argv)
if not a.image_dir.is_dir():
print(f"error: image directory {a.image_dir} does not exist", file=sys.stderr)
return 2
station = Station(a.image_dir, a.state, a.stall_after)
httpd = ThreadingHTTPServer((a.bind, a.port), Handler)
httpd.daemon_threads = True
httpd.station = station # type: ignore[attr-defined]
images = sorted(p.name for p in a.image_dir.glob("ninlight_*_ota.img"))
print(f"{now_iso()} listening on {a.bind}:{a.port}, {len(images)} image(s): "
f"{', '.join(images) or 'none'}", flush=True)
if a.stall_after is not None:
print(f"{now_iso()} WARNING: --stall-after {a.stall_after} is a test aid", flush=True)
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
return 0
if __name__ == "__main__":
raise SystemExit(main())
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Richtet einen Raspberry Pi als ninlight-Recovery-Station ein. Idempotent:
# mehrfaches Ausfuehren aendert nichts und schadet nicht.
#
# Aufruf: tools/station/setup_pi.sh <pi-host>
set -euo pipefail
HOST=${1:?Aufruf: tools/station/setup_pi.sh <pi-host>}
HERE=$(cd "$(dirname "$0")" && pwd)
# /usr/bin/ssh und /usr/bin/scp statt Varianten im PATH, die sich anders verhalten koennen.
# Ueberschreibbar (wie NINLIGHT_STATION_ROOT in deploy.sh), damit ein Test gegen Stubs statt
# einen echten Pi laufen kann.
SSH_BIN=${NINLIGHT_SSH_BIN:-/usr/bin/ssh}
SCP_BIN=${NINLIGHT_SCP_BIN:-/usr/bin/scp}
echo "== Voraussetzungen auf $HOST pruefen =="
"$SSH_BIN" "$HOST" 'set -e
systemctl is-active --quiet NetworkManager || { echo "NetworkManager laeuft nicht - dieser Plan setzt ihn voraus" >&2; exit 1; }
nmcli -t -f DEVICE,STATE device | grep -q "^wlan0:" || { echo "wlan0 wird nicht von NetworkManager verwaltet" >&2; exit 1; }
python3 -c "import sys; sys.exit(0 if sys.version_info >= (3,11) else 1)" || { echo "Python 3.11+ noetig" >&2; exit 1; }
echo " ok"'
echo "== Konfiguration =="
"$SCP_BIN" -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
"$SSH_BIN" "$HOST" 'set -e
if [ -f /etc/ninlight-station.conf ]; then
echo " /etc/ninlight-station.conf existiert bereits, bleibt unveraendert"
else
sudo install -m 0600 /tmp/ninlight-station.conf.example /etc/ninlight-station.conf
echo " /etc/ninlight-station.conf aus der Vorlage angelegt"
fi
rm -f /tmp/ninlight-station.conf.example'
echo "== Server, CLI und Unit installieren =="
"$SCP_BIN" -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
"$SSH_BIN" "$HOST" 'set -e
sudo install -d -m 0755 /opt/ninlight-station /opt/ninlight-station/images
sudo install -m 0755 /tmp/server.py /opt/ninlight-station/server.py
sudo install -m 0755 /tmp/ninlight-station /usr/local/bin/ninlight-station
sudo install -m 0644 /tmp/ninlight-station.service /etc/systemd/system/ninlight-station.service
rm -f /tmp/server.py /tmp/ninlight-station /tmp/ninlight-station.service
sudo systemctl daemon-reload
echo " installiert"'
echo "== NetworkManager-Profil =="
# Zwei Dinge, die man an einer echten Lampenstation nicht falsch machen darf:
# - /etc/ninlight-station.conf ist 0600 root:root (das Passwort soll nicht
# jeder lesen koennen). Der SSH-Benutzer ist aber nicht root, direktes
# ". /etc/ninlight-station.conf" wuerde also mit "Permission denied"
# abbrechen. "sudo cat" + eval liest sie dennoch ein, ohne die Rechte der
# Datei aufzuweichen.
# - "nmcli ... wifi-sec.psk $PSK" wuerde das Passwort als Kommandozeilen-
# argument an einen Prozess uebergeben - fuer jeden lokalen Benutzer per
# "ps"/"/proc/<pid>/cmdline" sichtbar. Deshalb wird das Verbindungsprofil
# stattdessen direkt als NetworkManager-Keyfile (0600 root:root)
# geschrieben; das Passwort landet nur in einer Root-Datei, nie in einer
# Prozessliste.
"$SSH_BIN" "$HOST" bash -s <<'REMOTE'
set -e
eval "$(sudo cat /etc/ninlight-station.conf)"
CONN_FILE="/etc/NetworkManager/system-connections/${AP_CONNECTION}.nmconnection"
if sudo test -f "$CONN_FILE"; then
UUID=$(sudo sed -n 's/^uuid=//p' "$CONN_FILE" | head -n1)
ACTION="aktualisiert"
else
UUID=$(cat /proc/sys/kernel/random/uuid)
ACTION="angelegt"
fi
TMP=$(mktemp)
trap 'rm -f "$TMP"' EXIT
{
printf '%s\n' '[connection]'
printf 'id=%s\n' "$AP_CONNECTION"
printf 'uuid=%s\n' "$UUID"
printf '%s\n' 'type=wifi'
printf '%s\n' 'interface-name=wlan0'
printf '%s\n' 'autoconnect=false'
printf '\n%s\n' '[wifi]'
printf '%s\n' 'mode=ap'
printf 'ssid=%s\n' "$SSID"
printf '%s\n' 'band=bg'
printf '%s\n' 'channel=6'
printf '\n%s\n' '[wifi-security]'
printf '%s\n' 'key-mgmt=wpa-psk'
printf '%s\n' 'proto=rsn'
printf '%s\n' 'pairwise=ccmp;'
printf '%s\n' 'group=ccmp;'
printf 'psk=%s\n' "$PSK"
printf '\n%s\n' '[ipv4]'
printf '%s\n' 'method=shared'
printf '%s\n' 'address1=192.168.8.1/24'
printf '\n%s\n' '[ipv6]'
printf '%s\n' 'method=disabled'
} >"$TMP"
sudo install -o root -g root -m 0600 "$TMP" "$CONN_FILE"
rm -f "$TMP"
sudo nmcli connection load "$CONN_FILE" >/dev/null
echo " Profil $AP_CONNECTION $ACTION"
REMOTE
cat <<'EOF'
Fertig. Die Station ist absichtlich noch AUS.
Naechste Schritte:
tools/station/deploy.sh <pi-host> Images ausliefern
ssh <pi-host> ninlight-station on Station einschalten
ssh <pi-host> ninlight-station off wieder ausschalten (nicht vergessen)
EOF
+26
View File
@@ -0,0 +1,26 @@
# /etc/ninlight-station.conf - von tools/station/setup_pi.sh angelegt.
# Muss zu den Build-Parametern der Firmware passen (NINLIGHT_RECOVERY_SSID,
# NINLIGHT_RECOVERY_PASSWORD in config.local.cmake). Weicht etwas ab, findet
# die Lampe die Station nicht.
#
# Werte immer in einfachen Anführungszeichen: ninlight-station liest diese
# Datei per POSIX ". datei" ein, und dabei wertet die Shell auch in doppelten
# Anführungszeichen $ und ` aus - ein Passwort mit "$1" wuerde so lautlos
# durch ein Shell-Argument ersetzt. Einfache Anführungszeichen unterbinden
# das; systemds EnvironmentFile= entfernt sie ebenso wie doppelte und fuehrt
# ohnehin nie eine Shell aus.
SSID='ninlight-recovery'
# Oeffentlicher Default. Fuer den echten Einsatz hier UND in config.local.cmake
# einen eigenen Wert setzen - wer das Passwort kennt und in Funkreichweite ist,
# kann jeder Lampe beim naechsten Boot beliebige Firmware unterschieben.
PSK='ninlight-recovery'
IMAGE_DIR='/opt/ninlight-station/images'
# NetworkManager-Profil, in das "ninlight-station off" zurueckschaltet.
HOME_CONNECTION='preconfigured'
# NetworkManager-Profil der Station selbst.
AP_CONNECTION='ninlight-recovery'
+365
View File
@@ -0,0 +1,365 @@
#!/usr/bin/env bash
# Stub-Tests fuer deploy.sh - ohne Pi ausfuehrbar.
#
# ssh/rsync werden im PATH durch Stubs ersetzt: ssh fuehrt das per Heredoc
# uebergebene Remote-Skript lokal aus (statt es an einen echten Pi zu
# schicken), rsync uebersetzt "host:pfad"-Ziele auf den blossen lokalen Pfad
# und ruft dann den echten rsync auf. So laeuft die komplette Logik von
# deploy.sh - inklusive des Installationsschritts, der normalerweise per SSH
# auf dem Pi passiert - tatsaechlich, aber vollstaendig gegen ein Sandbox-
# Verzeichnis (NINLIGHT_STATION_ROOT/…) statt gegen echte Systempfade.
#
# Aufruf: tools/station/tests/test_deploy.sh
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
STATION_DIR=$(cd "$HERE/.." && pwd)
REAL_RSYNC=$(command -v rsync)
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
FAILED=0
pass() { echo "ok - $1"; }
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
# --- Sandbox-Repo aufbauen: tools/station/deploy.sh + tools/mkimage/nl_mkimage.py + build/ ---
setup_sandbox() {
local sandbox=$1
rm -rf "$sandbox"
mkdir -p "$sandbox/tools/station" "$sandbox/tools/mkimage" "$sandbox/build"
cp "$STATION_DIR/deploy.sh" "$sandbox/tools/station/deploy.sh"
chmod +x "$sandbox/tools/station/deploy.sh"
# Inhalt irrelevant, die Dateien muessen nur existieren (werden "uebertragen").
echo "# dummy" >"$sandbox/tools/station/server.py"
echo "# dummy" >"$sandbox/tools/station/ninlight-station"
echo "# dummy" >"$sandbox/tools/station/ninlight-station.service"
# Fake nl_mkimage.py --verify: verhaelt sich wie das echte Tool (eine Zeile
# "<geraet> <version> <bytes>" auf stdout bei Erfolg, Exit 0; eine Zeile auf
# stderr bei Fehlschlag, Exit 1), aber steuerbar per Umgebungsvariable
# MKIMAGE_FAIL_SUBSTR: enthaelt der zu pruefende Dateiname diesen Teilstring,
# schlaegt die Pruefung fehl.
cat >"$sandbox/tools/mkimage/nl_mkimage.py" <<'PY'
import os
import sys
args = sys.argv[1:]
f = args[args.index("--verify") + 1]
target = args[args.index("--expect-device") + 1]
fail_substr = os.environ.get("MKIMAGE_FAIL_SUBSTR", "")
if fail_substr and fail_substr in f:
print(f"Fehler: {f} besteht die Trailer-Pruefung nicht (Test-Stub)", file=sys.stderr)
sys.exit(1)
size = os.path.getsize(f)
print(f"{target} 1.0.0 {size}")
PY
}
# --- Stub-Kommandos fuer ssh/rsync im PATH ---
setup_stubbin() {
local bindir=$1 log=$2
mkdir -p "$bindir"
cat >"$bindir/ssh" <<EOF
#!/usr/bin/env bash
echo "STUB ssh \$*" >>"$log"
host=\$1; shift
"\$@"
EOF
cat >"$bindir/rsync" <<EOF
#!/usr/bin/env bash
echo "STUB rsync \$*" >>"$log"
args=()
for a in "\$@"; do
case "\$a" in
-*) args+=("\$a") ;;
*:*) args+=("\${a#*:}") ;;
*) args+=("\$a") ;;
esac
done
exec "$REAL_RSYNC" "\${args[@]}"
EOF
cat >"$bindir/scp" <<EOF
#!/usr/bin/env bash
echo "STUB scp \$* - deploy.sh sollte scp nicht aufrufen" >>"$log"
exit 1
EOF
cat >"$bindir/sudo" <<EOF
#!/usr/bin/env bash
echo "STUB sudo \$*" >>"$log"
exec "\$@"
EOF
cat >"$bindir/systemctl" <<EOF
#!/usr/bin/env bash
echo "STUB systemctl \$*" >>"$log"
case "\$1" in
is-active) exit "\${FAKE_SYSTEMCTL_ACTIVE_EXIT:-1}" ;;
restart) exit "\${FAKE_SYSTEMCTL_RESTART_EXIT:-0}" ;;
*) exit 0 ;;
esac
EOF
chmod +x "$bindir"/ssh "$bindir"/rsync "$bindir"/scp "$bindir"/sudo "$bindir"/systemctl
}
run_deploy() {
# Fuehrt deploy.sh mit stubbtem PATH aus; Ausgabe landet in $OUT, Exit-Code in $?.
local sandbox=$1; shift
# Auf dem echten Pi existieren /usr/local/bin und /etc/systemd/system schon;
# in der Sandbox muessen sie fuer "install" eigens angelegt werden.
mkdir -p "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
PATH="$STUBBIN:$PATH" \
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
"$sandbox/tools/station/deploy.sh" "$@"
}
echo "=== Szenario 1: Image besteht die Trailer-Pruefung nicht -> nichts uebertragen ==="
SANDBOX="$TMPROOT/s1"; STUBBIN="$TMPROOT/bin1"; LOG="$TMPROOT/s1.log"; SANDBOX_STATE="$TMPROOT/s1-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
echo "dummy" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(MKIMAGE_FAIL_SUBSTR=foo run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
if echo "$OUT" | grep -q "besteht die Trailer-Pruefung nicht"; then
pass "Fehlermeldung zur Trailer-Pruefung vorhanden"
else
fail "Fehlermeldung zur Trailer-Pruefung fehlt"
fi
if ! grep -q "STUB ssh\|STUB rsync\|STUB scp" "$LOG"; then
pass "kein ssh/rsync/scp aufgerufen - nichts wurde uebertragen"
else
fail "ssh/rsync/scp wurde trotz fehlgeschlagener Pruefung aufgerufen: $(cat "$LOG")"
fi
echo
echo "=== Szenario 2: keine Images unter build/ gefunden ==="
SANDBOX="$TMPROOT/s2"; STUBBIN="$TMPROOT/bin2"; LOG="$TMPROOT/s2.log"; SANDBOX_STATE="$TMPROOT/s2-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0"; fi
if echo "$OUT" | grep -q "keine Images unter"; then
pass "Fehlermeldung zu fehlenden Images vorhanden"
else
fail "Fehlermeldung zu fehlenden Images fehlt"
fi
if ! grep -q "STUB" "$LOG" 2>/dev/null; then
pass "kein Transfer-Kommando aufgerufen"
else
fail "ein Transfer-Kommando wurde trotz fehlender Images aufgerufen: $(cat "$LOG")"
fi
echo
echo "=== Szenario 3: gezielter Deploy eines Geraetetyps laesst andere Images auf dem Ziel unangetastet (Critical 2) ==="
SANDBOX="$TMPROOT/s3"; STUBBIN="$TMPROOT/bin3"; LOG="$TMPROOT/s3.log"; SANDBOX_STATE="$TMPROOT/s3-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX/build/bar"
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
echo "bar-image" >"$SANDBOX/build/bar/ninlight_bar_ota.img"
# Zielverzeichnis simuliert einen vorherigen Voll-Deploy: Images fuer foo UND bar liegen schon da.
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
echo "foo-image-alt" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img"
echo "bar-image" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test foo 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
if echo "$OUT" | grep -q "andere Geraetetypen im Zielverzeichnis bleiben erhalten"; then
pass "Ausgabe kuendigt gezielten Deploy ohne --delete an"
else
fail "Ausgabe erwaehnt den gezielten Deploy nicht"
fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_bar_ota.img" ]; then
pass "Image des NICHT ausgewaehlten Geraetetyps (bar) blieb erhalten"
else
fail "Image des nicht ausgewaehlten Geraetetyps (bar) wurde geloescht - Critical 2 nicht behoben"
fi
if [ -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img" ] \
&& [ "$(cat "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_foo_ota.img")" = "foo-image-neu" ]; then
pass "Image des ausgewaehlten Geraetetyps (foo) wurde aktualisiert"
else
fail "Image des ausgewaehlten Geraetetyps (foo) wurde nicht aktualisiert"
fi
echo
echo "=== Szenario 4: Voll-Deploy (kein target) entfernt veraltete Images - zur Abgrenzung von Szenario 3 ==="
SANDBOX="$TMPROOT/s4"; STUBBIN="$TMPROOT/bin4"; LOG="$TMPROOT/s4.log"; SANDBOX_STATE="$TMPROOT/s4-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
echo "veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" ]; then
pass "veraltetes Image wurde beim Voll-Deploy entfernt (--delete wirkt weiterhin, wenn gewollt)"
else
fail "veraltetes Image wurde beim Voll-Deploy NICHT entfernt"
fi
echo
echo "=== Szenario 5: Dienst laeuft, Neustart nach dem Deploy schlaegt fehl -> Exit != 0 (Minor) ==="
SANDBOX="$TMPROOT/s5"; STUBBIN="$TMPROOT/bin5"; LOG="$TMPROOT/s5.log"; SANDBOX_STATE="$TMPROOT/s5-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo" "$SANDBOX_STATE/usr/local/bin" "$SANDBOX_STATE/etc/systemd/system"
echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(FAKE_SYSTEMCTL_ACTIVE_EXIT=0 FAKE_SYSTEMCTL_RESTART_EXIT=1 \
PATH="$STUBBIN:$PATH" \
NINLIGHT_SSH_BIN="$STUBBIN/ssh" \
NINLIGHT_STATION_ROOT="$SANDBOX_STATE/opt/ninlight-station" \
NINLIGHT_STATION_BIN="$SANDBOX_STATE/usr/local/bin/ninlight-station" \
NINLIGHT_STATION_UNIT="$SANDBOX_STATE/etc/systemd/system/ninlight-station.service" \
"$SANDBOX/tools/station/deploy.sh" pi-test foo 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0, wenn der Neustart fehlschlaegt ($RC)"; else fail "Exit-Code war 0, obwohl der Neustart fehlschlug"; fi
if echo "$OUT" | grep -q "Neustart nach dem Deploy ist aber fehlgeschlagen"; then
pass "Fehlermeldung unterscheidet Neustart-Fehlschlag von 'Dienst laeuft nicht'"
else
fail "Fehlermeldung fehlt oder ist mehrdeutig"
fi
if echo "$OUT" | grep -q "Dienst laeuft nicht"; then
fail "faelschlich 'Dienst laeuft nicht' gemeldet, obwohl der Dienst lief"
else
pass "'Dienst laeuft nicht' wird nicht faelschlich gemeldet"
fi
echo
echo "=== Szenario 6: deploy.sh/setup_pi.sh nutzen ohne Override /usr/bin/ssh (bzw. scp) ==="
# Die anderen Szenarien laufen mit NINLIGHT_SSH_BIN auf einen Stub umgebogen - das prueft die
# Logik, aber nicht mehr, WELCHEN ssh das Skript ohne Override tatsaechlich waehlt. Der Nutzer
# verlangt ausdruecklich /usr/bin/ssh und /usr/bin/scp statt einer Variante im PATH; das hier
# ist die Absicherung dagegen, dass der Default beim naechsten Umbau stillschweigend zurueck
# auf ein PATH-"ssh" rutscht.
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/deploy.sh"; then
pass "deploy.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
else
fail "deploy.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
fi
if grep -q 'SSH_BIN=\${NINLIGHT_SSH_BIN:-/usr/bin/ssh}' "$STATION_DIR/setup_pi.sh"; then
pass "setup_pi.sh: Default fuer SSH_BIN ist /usr/bin/ssh"
else
fail "setup_pi.sh: Default fuer SSH_BIN ist nicht mehr /usr/bin/ssh"
fi
if grep -q 'SCP_BIN=\${NINLIGHT_SCP_BIN:-/usr/bin/scp}' "$STATION_DIR/setup_pi.sh"; then
pass "setup_pi.sh: Default fuer SCP_BIN ist /usr/bin/scp"
else
fail "setup_pi.sh: Default fuer SCP_BIN ist nicht mehr /usr/bin/scp"
fi
echo
echo "=== Szenario 7: Voll-Deploy mit zwei Build-Verzeichnissen fuer denselben Dateinamen -> Abbruch (B1) ==="
SANDBOX="$TMPROOT/s7"; STUBBIN="$TMPROOT/bin7"; LOG="$TMPROOT/s7.log"; SANDBOX_STATE="$TMPROOT/s7-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/evk" "$SANDBOX/build/evk-onb"
echo "evk-mit-wlan" >"$SANDBOX/build/evk/ninlight_evk_ota.img"
echo "evk-ohne-wlan" >"$SANDBOX/build/evk-onb/ninlight_evk_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
if [ "$RC" -ne 0 ]; then pass "Exit-Code != 0 ($RC)"; else fail "Exit-Code war 0, obwohl zwei Fundorte denselben Dateinamen liefern"; fi
if echo "$OUT" | grep -q "ninlight_evk_ota.img mehrfach gefunden"; then
pass "Fehlermeldung nennt den doppelten Dateinamen"
else
fail "Fehlermeldung zum doppelten Dateinamen fehlt: $OUT"
fi
if echo "$OUT" | grep -q "build/evk/ninlight_evk_ota.img" && echo "$OUT" | grep -q "build/evk-onb/ninlight_evk_ota.img"; then
pass "Fehlermeldung nennt beide Fundorte"
else
fail "Fehlermeldung nennt nicht beide Fundorte: $OUT"
fi
if ! grep -q "STUB" "$LOG" 2>/dev/null; then
pass "kein Transfer-Kommando aufgerufen - nichts wurde stillschweigend uebertragen"
else
fail "ein Transfer-Kommando wurde trotz doppeltem Dateinamen aufgerufen: $(cat "$LOG")"
fi
echo
echo "=== Szenario 8: Voll-Deploy zeigt vor dem Loeschen, welche Images verschwinden (B2) ==="
SANDBOX="$TMPROOT/s8"; STUBBIN="$TMPROOT/bin8"; LOG="$TMPROOT/s8.log"; SANDBOX_STATE="$TMPROOT/s8-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
echo "foo-image-neu" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
mkdir -p "$SANDBOX_STATE/opt/ninlight-station/images"
echo "veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img"
echo "auch-veraltet" >"$SANDBOX_STATE/opt/ninlight-station/images/ninlight_e14_cw_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
if echo "$OUT" | grep -q "folgende Images werden entfernt"; then
pass "Ausgabe kuendigt die zu entfernenden Images an, bevor geloescht wird"
else
fail "Ausgabe nennt die zu entfernenden Images nicht: $OUT"
fi
if echo "$OUT" | grep -q "ninlight_veraltet_ota.img" && echo "$OUT" | grep -q "ninlight_e14_cw_ota.img"; then
pass "beide zu entfernenden Dateien werden namentlich genannt"
else
fail "nicht alle zu entfernenden Dateien werden genannt: $OUT"
fi
if [ ! -f "$SANDBOX_STATE/opt/ninlight-station/images/ninlight_veraltet_ota.img" ]; then
pass "veraltetes Image wurde nach der Ankuendigung tatsaechlich entfernt"
else
fail "veraltetes Image wurde trotz Ankuendigung nicht entfernt"
fi
echo
echo "=== Szenario 9: Voll-Deploy ohne verwaiste Images meldet das statt zu schweigen (B2) ==="
SANDBOX="$TMPROOT/s9"; STUBBIN="$TMPROOT/bin9"; LOG="$TMPROOT/s9.log"; SANDBOX_STATE="$TMPROOT/s9-state"
setup_sandbox "$SANDBOX"
setup_stubbin "$STUBBIN" "$LOG"
mkdir -p "$SANDBOX/build/foo"
echo "foo-image" >"$SANDBOX/build/foo/ninlight_foo_ota.img"
: >"$LOG"
set +e
OUT=$(run_deploy "$SANDBOX" pi-test 2>&1)
RC=$?
set -e
if [ "$RC" -eq 0 ]; then pass "Exit-Code 0"; else fail "Exit-Code $RC, Ausgabe: $OUT"; fi
if echo "$OUT" | grep -q "keine Images werden entfernt"; then
pass "Ausgabe meldet explizit, dass nichts entfernt wird"
else
fail "Ausgabe meldet nicht, dass nichts entfernt wird: $OUT"
fi
echo
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
else
echo "Mindestens ein Test ist fehlgeschlagen." >&2
fi
exit "$FAILED"
+455
View File
@@ -0,0 +1,455 @@
import hashlib
import http.client
import json
import os
import select
import socket
import stat
import subprocess
import sys
import threading
import time
from pathlib import Path
import pytest
SERVER = Path(__file__).resolve().parents[1] / "server.py"
TRAILER_SIZE = 512
RECENT_MAX = 8
def make_image(path: Path, device: str, version: str, payload: bytes) -> bytes:
"""Builds a file with the same trailer layout the firmware verifies."""
trailer = bytearray(b"\xff" * TRAILER_SIZE)
trailer[0:4] = b"NINL"
trailer[4] = 1
trailer[5:21] = device.encode().ljust(16, b"\x00")
trailer[21:37] = version.encode().ljust(16, b"\x00")
trailer[37:69] = hashlib.sha256(payload).digest()
data = payload + bytes(trailer)
path.write_bytes(data)
return data
def free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
class Station:
def __init__(self, tmp_path, *extra):
self.image_dir = tmp_path / "images"
self.image_dir.mkdir()
self.state = tmp_path / "status.json"
self.port = free_port()
self.proc = subprocess.Popen(
[sys.executable, str(SERVER), "--image-dir", str(self.image_dir),
"--bind", "127.0.0.1", "--port", str(self.port),
"--state", str(self.state), *extra],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
deadline = time.time() + 10
while time.time() < deadline:
try:
socket.create_connection(("127.0.0.1", self.port), 0.2).close()
return
except OSError:
if self.proc.poll() is not None:
raise RuntimeError("server died: " + (self.proc.stdout.read() or ""))
time.sleep(0.05)
raise RuntimeError("server did not come up")
def conn(self):
return http.client.HTTPConnection("127.0.0.1", self.port, timeout=5)
def read_state(self, predicate=None):
"""Reads status.json, optionally waiting for it to satisfy `predicate`.
The server only writes the entry for a request after that request's response has
actually finished (or failed) on the wire - by design, so it never claims success
before the last byte is confirmed sent (see server.py). A client that has just
finished reading its own response can therefore still see a status.json that is a
few milliseconds stale. Rather than have the server promise something before it is
true, we wait here for the state to catch up.
"""
last = None
for _ in range(100):
if self.state.exists():
try:
last = json.loads(self.state.read_text())
if predicate is None or predicate(last):
return last
except json.JSONDecodeError:
pass
time.sleep(0.05)
if last is None:
raise AssertionError("no state file written")
raise AssertionError(f"state never satisfied predicate, last seen: {last!r}")
def stop(self):
self.proc.terminate()
self.proc.wait(timeout=5)
def wait_for_log_line(self, contains, timeout=3.0):
"""Reads the server's merged stdout/stderr (see __init__) until a line containing
`contains` shows up, skipping unrelated lines (e.g. the startup banner) - used to
check that a request actually produced a log line (F3: every request must,
including rejected ones and errors the base handler catches before routing).
Reads the raw fd directly with os.read() instead of self.proc.stdout.readline():
readline() goes through TextIOWrapper's internal BufferedReader, whose underlying
read() can slurp everything currently sitting in the pipe (banner *and* the log
line we're waiting for) into its own buffer on the very first call, while only
handing back the first line. select() then reports the fd as not-readable on the
next iteration - the data isn't gone, it is just buffered somewhere select() can't
see - and a fixed-size test timeout would flake on exactly the line it's checking
for.
"""
fd = self.proc.stdout.fileno()
buf = getattr(self, "_log_buf", "")
deadline = time.time() + timeout
while True:
while "\n" in buf:
line, buf = buf.split("\n", 1)
self._log_buf = buf
if contains in line:
return line + "\n"
remaining = deadline - time.time()
if remaining <= 0:
self._log_buf = buf
return None
r, _, _ = select.select([fd], [], [], remaining)
if not r:
self._log_buf = buf
return None
chunk = os.read(fd, 65536)
if not chunk:
self._log_buf = buf
return None
buf += chunk.decode("utf-8", "replace")
@pytest.fixture
def station(tmp_path):
s = Station(tmp_path)
yield s
s.stop()
def test_serves_a_known_image(station):
data = make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.2.3", b"payload" * 500)
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
r = c.getresponse()
body = r.read()
assert r.status == 200
assert int(r.getheader("Content-Length")) == len(data)
assert body == data
def test_missing_image_is_404(station):
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img")
assert c.getresponse().status == 404
def test_unknown_target_is_404(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"x" * 100)
for path in ("/ninlight_nosuch_ota.img", "/ninlight_evk_ota.img.bak", "/", "/images/"):
c = station.conn()
c.request("GET", path)
assert c.getresponse().status == 404, path
def test_path_traversal_is_refused(station):
secret = station.image_dir.parent / "secret.txt"
secret.write_text("nicht ausliefern")
c = station.conn()
c.request("GET", "/../secret.txt")
assert c.getresponse().status == 404
def test_other_methods_are_405(station):
c = station.conn()
c.request("POST", "/ninlight_evk_ota.img", body=b"")
assert c.getresponse().status == 405
def test_rejected_method_produces_a_log_line(station):
"""F3: do_POST (and PUT/DELETE/HEAD) used to call reject() without logging anything -
the station is the only diagnostic tool during a rescue, and the spec requires a line
per request, rejections included."""
c = station.conn()
c.request("POST", "/ninlight_evk_ota.img", body=b"")
assert c.getresponse().status == 405
line = station.wait_for_log_line(" 405 ")
assert line is not None, "kein Log-Eintrag fuer die abgelehnte POST-Anfrage"
assert "/ninlight_evk_ota.img" in line
def test_log_error_is_not_silenced_by_log_message(capsys):
"""F3: BaseHTTPRequestHandler.log_error() calls log_message() by default (used e.g. for
a malformed request line or a client timeout, cases that never reach do_GET/do_POST).
server.py silences log_message() in favour of its own per-request log() calls - which
used to silence log_error() right along with it, since it wasn't overridden separately.
This exercises Handler.log_error() directly rather than over a raw socket: this
sandbox's network layer intercepts/rewrites malformed non-HTTP payloads before they
reach the server (observed via a spoofed 'Server: aiohttp' response), which makes a
genuine malformed-request-line test unreliable here."""
import importlib.util
spec = importlib.util.spec_from_file_location("ninlight_station_server", SERVER)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class _Stub:
client_address = ("203.0.113.5", 54321)
module.Handler.log_error(_Stub(), "code %d, message %s", 400, "Bad request version")
out = capsys.readouterr().out
assert "ERROR" in out
assert "203.0.113.5" in out
assert "code 400, message Bad request version" in out
def test_range_request_returns_206_and_the_right_slice(station):
data = make_image(station.image_dir / "ninlight_e27_rgb_ota.img", "e27_rgb", "2.0.0", b"abc" * 2000)
c = station.conn()
c.request("GET", "/ninlight_e27_rgb_ota.img", headers={"Range": "bytes=1000-"})
r = c.getresponse()
body = r.read()
assert r.status == 206
assert r.getheader("Content-Range") == f"bytes 1000-{len(data)-1}/{len(data)}"
assert body == data[1000:]
def test_state_file_records_a_delivery(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "0.3.4", b"z" * 1000)
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
state = station.read_state(lambda s: s["served"] >= 1)
assert state["served"] == 1
entry = state["recent"][-1]
assert entry["target"] == "evk"
assert entry["version"] == "0.3.4"
assert entry["status"] == "ok"
assert entry["ip"] == "127.0.0.1"
assert entry["bytes"] == 1000 + TRAILER_SIZE
def test_state_file_records_a_missing_image(station):
c = station.conn()
c.request("GET", "/ninlight_g95_cw_ota.img")
c.getresponse().read()
state = station.read_state(lambda s: len(s["recent"]) >= 1)
assert state["served"] == 0
assert state["recent"][-1]["status"] == "notfound"
assert state["recent"][-1]["target"] == "g95_cw"
def test_state_file_is_world_readable_even_under_a_restrictive_umask(tmp_path):
"""status.json is written by a root service but read by a normal-user CLI.
tempfile.mkstemp() creates its file 0600 and os.replace() preserves whatever mode
the temp file had, so the server must explicitly widen the mode before the rename -
and must do so with chmod() rather than relying on the mode passed to mkstemp/open,
because that one is still narrowed by the process umask. A restrictive umask (as
root services on the Pi may well run under) must not bring the 0600 back.
"""
old_umask = os.umask(0o077)
try:
s = Station(tmp_path)
finally:
os.umask(old_umask)
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"w" * 100)
def world_readable() -> bool:
mode = stat.S_IMODE(s.state.stat().st_mode)
return bool(mode & stat.S_IROTH)
# Written once already at startup (Station.__init__ -> write_state()).
s.read_state(lambda _: world_readable())
assert world_readable(), "status.json is not world-readable after the initial write"
# A second write (a real delivery) must not lose the mode either - e.g. if the
# widening were only applied to the file's very first creation instead of to
# every temp file, mode 0600 would silently come back on every later write.
c = s.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
s.read_state(lambda st: st["served"] >= 1)
assert world_readable(), "status.json lost its mode on a later overwrite"
finally:
s.stop()
def test_recent_is_capped_at_eight(station):
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"q" * 10)
for _ in range(12):
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
c.getresponse().read()
state = station.read_state(lambda s: s["served"] >= 12)
assert state["served"] == 12
assert len(state["recent"]) == RECENT_MAX
def test_aborted_transfer_is_recorded(tmp_path):
s = Station(tmp_path, "--stall-after", "256")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 100000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
sock.recv(4096)
time.sleep(0.3)
sock.close()
deadline = time.time() + 10
while time.time() < deadline:
try:
entry = json.loads(s.state.read_text())["recent"][-1]
if entry["status"] == "aborted":
assert entry["bytes"] < 100000
return
except (OSError, json.JSONDecodeError, IndexError, KeyError):
pass
time.sleep(0.1)
raise AssertionError("abort was not recorded")
finally:
s.stop()
def test_stall_after_stops_sending_but_keeps_the_connection(tmp_path):
s = Station(tmp_path, "--stall-after", "512")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
got = b""
sock.settimeout(2)
try:
while len(got) < 200000:
chunk = sock.recv(65536)
if not chunk:
break
got += chunk
except socket.timeout:
pass
# far less than the full image
assert len(got) < 20000
# and the connection is genuinely still open: a server that (wrongly) closed it
# after the stall boundary would make this recv() return b"" (EOF) instead of
# timing out. Just counting received bytes above would not catch that - a server
# closing the connection right after --stall-after also sends far less than
# 20000 bytes.
sock.settimeout(1)
with pytest.raises(socket.timeout):
sock.recv(1)
sock.close()
finally:
s.stop()
def test_stall_after_budget_survives_a_resumed_request(tmp_path):
"""--stall-after must stop a client for good, not just for one request.
On real hardware, a lamp whose download stalls hits its own receive timeout, gives up
on the connection, and opens a new one with a Range header picking up right where it
left off - exactly what test_stall_after_stops_sending_but_keeps_the_connection above
exercises for a single request. A per-request byte counter would hand that resumed
request a brand-new --stall-after allowance, so the "stall" would only ever delay
delivery instead of actually stopping it - which is what was observed on the EVK: the
server stalled at 65536 bytes each time, but the lamp reconnected five times and walked
away with the complete image. This test reproduces that reconnect and checks that the
second, resumed request gets no further bytes at all.
"""
s = Station(tmp_path, "--stall-after", "1000")
try:
make_image(s.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"y" * 200000)
# First request: burns through the whole --stall-after budget, then the server
# holds the connection open and sends nothing further.
sock = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\n\r\n")
got = b""
sock.settimeout(2)
try:
while len(got) < 200000:
chunk = sock.recv(65536)
if not chunk:
break
got += chunk
except socket.timeout:
pass
assert len(got) < 20000 # far less than the full image, same bound as above
sock.close() # the lamp's watchdog gives up and reconnects, like on real hardware
# Second request, resuming with Range exactly like a real lamp would. This
# client's cumulative budget is already spent, so it must get zero more bytes of
# body - not a fresh --stall-after allowance just because it's a new connection.
sock2 = socket.create_connection(("127.0.0.1", s.port), timeout=5)
sock2.sendall(b"GET /ninlight_evk_ota.img HTTP/1.1\r\nHost: x\r\nRange: bytes=1000-\r\n\r\n")
sock2.settimeout(2)
header = b""
while b"\r\n\r\n" not in header:
header += sock2.recv(4096)
assert b" 206 " in header.split(b"\r\n", 1)[0]
body_after_header = header.split(b"\r\n\r\n", 1)[1]
assert body_after_header == b"", "resumed request must not receive any body bytes"
try:
more = sock2.recv(1)
except socket.timeout:
more = None
assert more in (None, b""), "server sent data after the client's stall budget was spent"
sock2.close()
finally:
s.stop()
def test_concurrent_downloads_are_all_recorded(station):
"""Several lamps hitting the station at once must not lose or corrupt status.json.
Station.served is a shared counter and Station.recent a shared list, mutated from one
handler thread per connection under ThreadingHTTPServer. Without a lock around the
read-modify-write of `served` and the recent-list mutation + serialize-to-disk step,
concurrent requests can lose increments (two threads read the same old value) or have
one thread's status.json write clobber another's.
"""
make_image(station.image_dir / "ninlight_evk_ota.img", "evk", "1.0.0", b"c" * 5000)
expected_len = 5000 + TRAILER_SIZE
n_workers = 20
errors = []
results_lock = threading.Lock()
def worker():
try:
c = station.conn()
c.request("GET", "/ninlight_evk_ota.img")
r = c.getresponse()
body = r.read()
if r.status != 200 or len(body) != expected_len:
with results_lock:
errors.append((r.status, len(body)))
except Exception as e: # pragma: no cover - surfaced via the errors list below
with results_lock:
errors.append(e)
threads = [threading.Thread(target=worker) for _ in range(n_workers)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not errors, f"some downloads failed or returned the wrong body: {errors}"
state = station.read_state(lambda s: s["served"] >= n_workers)
assert state["served"] == n_workers
assert len(state["recent"]) == min(n_workers, RECENT_MAX)
for entry in state["recent"]:
assert entry["status"] == "ok"
assert entry["target"] == "evk"
assert entry["bytes"] == expected_len
+79
View File
@@ -0,0 +1,79 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "ninlight-station"
version = "0.1.0"
source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=8" }]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]