14 Commits
Author SHA1 Message Date
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
Michael SauerandClaude Opus 5 37a5fe2ca9 fix: uebersprungenes Recovery-Image ist kein Fehler mehr
Lehnte die Recovery ein Image ab, weil es bereits die laufende Version
traegt, meldete die Firmware das als last_ota_error=trailer. Wer eine
Lampe im Feld ausliest, haette dort einen Trailer-Fehler gesehen, wo in
Wahrheit nichts passiert ist - und die Meldung in der Konsole lautete
"update failed". Das bewusste Ueberspringen hat jetzt einen eigenen
Zustand (nl_ota_skipped()), setzt keinen Fehlerwert und wird als
"image already up to date" protokolliert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:22:17 +02:00
Michael SauerandClaude Opus 5 c5d76efb25 docs: auth_all-Ergebnis und die beiden offenen Punkte der EVK-Runde vermerken
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 11:00:50 +02:00
Michael SauerandClaude Opus 5 5e2dff375e fix: abgewiesener fw_update verdirbt last_ota_error des laufenden Updates nicht mehr
Ein zweiter fw_update waehrend eines laufenden Downloads setzte s_error
auf "busy". Da ota_task() nur bei leerem Fehlerfeld auf "download"
zurueckfaellt, wurde ein spaeter gescheiterter Transfer danach als
"busy" gemeldet statt als "download". Die Abweisung hat kein eigenes
Ergebnis - sie wird der API mit 409 gemeldet, last_ota_error bleibt beim
Ergebnis des laufenden bzw. letzten Updates.

Dazu die Doku auf das am EVK gemessene Verhalten korrigiert: stirbt die
Gegenstelle mitten im Download, haengt der Lesevorgang rund sieben
Minuten in einem TCP-Timeout und bricht ohne Range-Resume ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:58:09 +02:00
Michael SauerandClaude Opus 5 6adf22fb52 docs: EVK-Ergebnisse der OTA-Runde eintragen
Sieben Punkte bestanden. Der zweite fw_update nach einer Ablehnung ging
erst nach dem SHA256-Kontext-Fix. Die einmalige lwIP-pbuf-Assertion beim
verfälschten Image ist als offene Beobachtung vermerkt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 10:42:09 +02:00
19 changed files with 1483 additions and 33 deletions
+10 -4
View File
@@ -94,19 +94,25 @@ Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit `409
(gleicher Wert wie `last_ota_error`). Läuft der Trailer- bzw. Hash-Check durch, resettet die
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 löst dagegen einen Wiederaufnahmeversuch per HTTP-Range aus (begrenzte
Anzahl Runden); erst wenn auch das ausgeht, landet der Versuch bei `last_ota_error = download`.
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.
Werte von `last_ota_error` (`null`, solange nichts fehlgeschlagen ist):
| Wert | Bedeutung |
|---|---|
| `download` | Verbindung/Transfer gescheitert (auch nach ausgeschöpftem Wiederaufnahmeversuch) |
| `download` | Verbindung/Transfer gescheitert |
| `crc` | Prüfsumme des SDK-eigenen Update-Mechanismus stimmt nicht |
| `trailer` | Trailer fehlt, ist zu kurz oder hat eine falsche Magic/Version |
| `device_mismatch` | Trailer nennt einen anderen Gerätetyp |
| `hash` | SHA256 im Trailer stimmt nicht mit dem übertragenen Image überein |
| `busy` | es läuft bereits ein Update |
| `busy` | die Firmware konnte den Download-Task nicht anlegen (Speichermangel) |
Ein `fw_update`, der abgewiesen wird, weil bereits ein Update läuft, beantwortet die API mit `409` und lässt `last_ota_error` unangetastet — dort steht weiterhin das Ergebnis des laufenden bzw. letzten Updates.
Ein erneuter `fw_update`-Aufruf mit einem gültigen Image funktioniert nach einer Ablehnung sofort, ohne
manuellen Reboot.
+23 -14
View File
@@ -39,22 +39,22 @@ Konsole: `tools/monitor.sh /dev/cu.<port>` (115200 8N1). Flashen: `tools/flash.s
- [x] C2: Onboarding ok, 60 s warten, dann Reboot und Router-Neustart → AP `ninlight-<mac4>` darf nicht wiederkommen (Scan am Mac) — 2026-09-12 (Reboot geprüft, Router-Neustart offen)
## OTA und Recovery (Plan 3)
- [ ] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version
- [ ] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter
- [ ] OTA: Image mit verfälschtem Byte wird abgelehnt (`last_ota_error` = `hash` oder `crc`), kein Reset
- [ ] OTA: HTTP-Server während des Downloads abschalten → Wiederaufnahmeversuch (Range-Resume) sichtbar in der Konsole, nach Ausschöpfen der Runden `last_ota_error` = `download`, Lampe bootet normal
- [ ] OTA: kurzer WLAN-Aussetzer während des Downloads (Verbindung kurz kappen und wiederherstellen) → Download wird fortgesetzt, kein sofortiger `download`-Fehler
- [ ] Recovery: Lampe zieht beim Booten das Image von der Station `ninlight-recovery`, Konsole zeigt Scan-, Join- und Fetch-Meldungen
- [ ] Recovery: ohne Station verlängert sich der Boot um höchstens ~3 s, Konsole loggt das Scan-Ergebnis (gefunden/nichts gefunden/Scan fehlgeschlagen)
- [x] OTA: Update über `fw_update` läuft durch, danach meldet `system/info` die neue Version — 2026-09-12 (0.3.0 → 0.3.1 → 0.3.2)
- [x] OTA: Image eines anderen Gerätetyps wird sofort abgelehnt (`last_ota_error` = `device_mismatch`), kein Reset, Lampe läuft mit der alten Firmware weiter — 2026-09-12 (Ablehnung nach ~4,5 s, Uptime lief durch)
- [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*
- [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
- [ ] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten)
- [ ] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version
- [ ] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200
- [ ] 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
- [ ] `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)
- [x] Einstellungen bleiben über ein OTA hinweg erhalten (WLAN, Name, Fade-Zeiten) — 2026-09-12 (Name, Fade 1200/800, pob, Lichtzustand)
- [x] Zweiter `fw_update` mit gültigem Image direkt nach einer Ablehnung, ohne zwischenzeitlichen Reboot → läuft durch, `system/info` meldet danach die neue Version — 2026-09-12 (erst nach dem SHA256-Kontext-Fix)
- [x] Lampe bleibt während und nach einer Ablehnung über REST erreichbar: `system/info` und `device/state` antworten beide weiterhin mit 200 — 2026-09-12 (system/info und device/state je 200)
- [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)
- [ ] OTA im AP-/Fallback-Modus (`mode=apsta`, kein Router erreichbar) und mit `auth_all = true` → Update läuft durch wie im normalen `sta`-Modus
- [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.
@@ -64,3 +64,12 @@ Für den echten Flottenbau `NINLIGHT_RECOVERY_PASSWORD` und `NINLIGHT_TOKEN` in
- [ ] E27 CW: Gegenphase CW sichtbar flackerfrei
- [ ] G95: Pinbelegung WW=PB15/CW=PB13 prüfen
- [ ] Farbtemperaturbereiche aller Varianten
## Offene Beobachtung aus der EVK-Session vom 2026-09-12
Beim Test mit dem verfälschten Image meldete lwIP einmalig
`Assertion "pbuf_free: p->ref > 0" failed at line 747 in pbuf.c` (10:29:23). Die Lampe lief
unbeeinträchtigt weiter und meldete korrekt `last_ota_error=crc`. Bei den Läufen mit fremdem
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.
+3 -3
View File
@@ -339,9 +339,9 @@ static void handle_command(const nl_api_ctx_t *ctx, const nl_http_request_t *req
else if (ctx->fw_update(url) != 0) {
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
* reason, not a blanket "not implemented". */
nl_sys_info_t in;
ctx->get_info(&in);
reply_error(resp, 409, in.last_ota_error ? in.last_ota_error : "fw_update busy or invalid url");
/* Do not reuse last_ota_error for this message: it belongs to the running or last
* update, not to the request we are rejecting right now. */
reply_error(resp, 409, "an update is already running");
} else
reply(resp, 202, "{\"status\":\"updating\"}");
} else if (strcmp(type, "reboot") == 0) {
+9 -3
View File
@@ -162,8 +162,14 @@ void nl_recovery_try(void)
tls_os_time_delay(HZ / 2);
waited += 500;
}
if (feeding)
printf("recovery: update failed (%s), continuing normal boot\r\n",
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
if (feeding) {
/* A skipped image (same version as the running firmware) is not a failure - saying so
would leave a healthy lamp looking like it had rejected a broken image. */
if (nl_ota_skipped())
printf("recovery: image already up to date, continuing normal boot\r\n");
else
printf("recovery: update failed (%s), continuing normal boot\r\n",
nl_ota_last_error() ? nl_ota_last_error() : "unknown");
}
}
}
+14 -3
View File
@@ -23,6 +23,7 @@ static char s_url[NL_OTA_URL_MAX];
static volatile bool s_busy;
static volatile bool s_reject_running_version; /* recovery path only, see nl_ota_start_recovery() */
static const char * volatile s_error;
static volatile bool s_skipped;
static u32 s_stack[NL_OTA_STACK];
static char s_tail_version[NL_TRAILER_VERSION_LEN]; /* used by the hook, kept off its 2 KB stack */
@@ -33,6 +34,7 @@ void nl_ota_init(void)
}
bool nl_ota_busy(void) { return s_busy; }
bool nl_ota_skipped(void) { return s_skipped; }
const char *nl_ota_last_error(void) { return s_error; }
void nl_ota_set_error(const char *e) { s_error = e; }
@@ -61,7 +63,10 @@ bool nl_fwup_verify_tail(const uint8_t sha256[32], const uint8_t *tail, uint32_t
if (strcmp(s_tail_version, NINLIGHT_FW_VERSION) == 0) {
printf("ota: recovery image already carries the running version %s, not flashing\r\n",
s_tail_version);
s_error = "trailer";
/* Deliberately skipped, not failed: leave last_ota_error alone. Reporting "trailer"
* here used to make a perfectly healthy lamp look like it had rejected a malformed
* image, which is exactly the wrong thing to read off a lamp in the field. */
s_skipped = true;
return false;
}
printf("ota: recovery image version %s (running %s)\r\n", s_tail_version, NINLIGHT_FW_VERSION);
@@ -90,7 +95,7 @@ static void ota_task(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)
if (s_error == NULL && !s_skipped)
s_error = "download";
printf("ota: failed (rc=%d, %s)\r\n", rc, s_error);
{
@@ -115,11 +120,17 @@ static int ota_start(const char *url, bool reject_running_version)
if (n <= 7 || n >= sizeof s_url) /* n == 7 is bare "http://" with no host */
return -1;
if (s_busy) {
s_error = "busy";
/* Do NOT touch s_error here. last_ota_error describes the outcome of the *running or last*
* update; a request we reject because one is already in flight has no outcome of its own.
* Writing "busy" into it used to poison the running attempt's slot: ota_task() only falls
* back to "download" when s_error is still NULL, so a failed transfer was afterwards
* reported as `busy` instead of `download`. Found on the EVK, 2026-09-12. The REST layer
* reports the rejection to its own caller with 409. */
return -1;
}
s_busy = true;
s_error = NULL;
s_skipped = false;
s_reject_running_version = reject_running_version;
memcpy(s_url, url, n + 1);
+3
View File
@@ -12,6 +12,9 @@ int nl_ota_start(const char *url);
* forever. A deliberate re-flash of the same version stays possible over the REST API. */
int nl_ota_start_recovery(const char *url);
bool nl_ota_busy(void);
/* True when the last recovery attempt deliberately skipped an image whose version already
matches the running firmware - that is not a failure and sets no last_ota_error. */
bool nl_ota_skipped(void);
/* NULL, or one of: download, crc, trailer, device_mismatch, hash, busy */
const char *nl_ota_last_error(void);
void nl_ota_set_error(const char *e);
+1 -1
View File
@@ -324,7 +324,7 @@ static void test_commands(void)
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
TEST_ASSERT_EQUAL_INT(409, resp.status);
TEST_ASSERT_EQUAL_STRING("fw_update busy or invalid url", error_text());
TEST_ASSERT_EQUAL_STRING("an update is already running", error_text());
TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url);
fake_fw_result = 0;
call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}");
+62 -5
View File
@@ -19,18 +19,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()
data = a.image.read_bytes()
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")
+155
View File
@@ -53,3 +53,158 @@ 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
+42
View File
@@ -0,0 +1,42 @@
# 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.
## 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.
+96
View File
@@ -0,0 +1,96 @@
#!/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"
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
while IFS= read -r f; do 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.
rsync -a -c --delete "$STAGE/" "$HOST:/tmp/ninlight-images/"
rsync -a "$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 "$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.
echo " voller Abgleich - Images ausserhalb der Auswahl werden entfernt"
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."
+113
View File
@@ -0,0 +1,113 @@
#!/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)"
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)
printf 'Dienst : %s\n' "$(systemctl is-active ninlight-station 2>/dev/null || echo inaktiv)"
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
[ -r /run/ninlight-station/status.json ] && { echo "Zuletzt ausgeliefert:"; sed 's/^/ /' /run/ninlight-station/status.json; }
;;
*)
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"]
+232
View File
@@ -0,0 +1,232 @@
#!/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 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] = []
# ThreadingHTTPServer runs one handler thread per connection, and several lamps
# can be downloading at once. Every mutation of served/recent 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 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-")
with os.fdopen(fd, "w") as f:
json.dump(payload, f, indent=2)
os.replace(tmp, self.state_path)
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(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:
self.reject(405, "method not allowed")
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
try:
with path.open("rb") as f:
f.seek(start)
while sent < remaining:
if self.station.stall_after is not None and sent >= self.station.stall_after:
# 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")
read_size = CHUNK
if self.station.stall_after is not None:
# Don't overshoot the stall boundary within a single read: without
# this a whole CHUNK could be sent before the check above ever
# notices we've passed --stall-after.
read_size = min(read_size, max(1, self.station.stall_after - sent))
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())
+103
View File
@@ -0,0 +1,103 @@
#!/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)
echo "== Voraussetzungen auf $HOST pruefen =="
ssh "$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 -q "$HERE/station.conf.example" "$HOST:/tmp/ninlight-station.conf.example"
ssh "$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 -q "$HERE/server.py" "$HERE/ninlight-station" "$HERE/ninlight-station.service" "$HOST:/tmp/"
ssh "$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 "$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'
+259
View File
@@ -0,0 +1,259 @@
#!/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_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_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
if [ "$FAILED" -eq 0 ]; then
echo "Alle Tests bestanden."
else
echo "Mindestens ein Test ist fehlgeschlagen." >&2
fi
exit "$FAILED"
+284
View File
@@ -0,0 +1,284 @@
import hashlib
import http.client
import json
import socket
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)
@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_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_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_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