Michael Sauer ad2f1b8486 fix(station): Manifest-Form, Teilschluessel-Faelle und Schluessel-Testisolation
Fixrunde 1 zu "Migrationsimages beim Deploy signieren":

- manifest.json traegt ninlight_version und ein neues built jetzt auf oberster
  Ebene statt pro Eintrag, dazu "file" statt der Redundanz einer Version pro
  Bild - genau die Form aus der Spec (Abschnitt 4) und aus server.py's
  Station.manifest_version(), die vorher immer "unbekannt" gelesen hat, weil
  sie ausschliesslich auf oberster Ebene sucht (C1). Ein Testfall schickt das
  Manifest jetzt zusaetzlich durch genau diese Funktion statt nur durch
  json.load.

- Ein Schluesselverzeichnis, das existiert, aber weder Produktions- noch
  Entwicklungsschluessel enthaelt, legt kein STAGE/migrate mehr an - das
  vorherige Verhalten liess dafuer trotzdem ein (leeres) migrate/ mit
  Manifest entstehen, das der volle rsync-Abgleich dann mit --delete gegen
  ein vorhandenes migrate/ auf dem Pi durchgesetzt und es geleert hat (C2).
  Aus demselben Grund war die _gz.img-Pflichtpruefung faelschlich
  unconditional: fehlte sie, brach sogar ein Deploy ab, der gar nicht
  signieren wollte, und nahm die Rettungsimages gleich mit (C3).

- Ein neues MIGRATE_COMPLETE-Flag (nur 1, wenn dieser Lauf mit BEIDEN
  Schluesseln signiert hat) entscheidet jetzt, ob der Abgleich auf dem Pi
  --delete benutzen und das Manifest ersetzen darf. Bei nur einem der beiden
  Schluessel oder einem gezielten Deploy synct migrate/ ohne --delete und
  merged das Manifest serverseitig mit dem vorhandenen zusammen, statt es zu
  ueberschreiben - vorher raeumte ein Voll-Deploy mit nur dem dev-Schluessel
  saemtliche _prod.img weg (C2, Teilfall), und ein gezielter Deploy liess ein
  Manifest zurueck, das die unveraendert gebliebenen Ziele nicht mehr
  erwaehnte (I1).

- ${HOME:-} statt $HOME beim Ermitteln von SIGN_DIR, damit ein Aufruf unter
  `set -u` ohne gesetztes HOME (cron, env -i) nicht unnoetig abbricht, obwohl
  nur die Rettungsimages gebraucht werden.

- tests/test_deploy.sh: die Sicherheitsabsicherung aus dem letzten Durchgang
  hatte selbst eine Luecke (C4) - "${NINLIGHT_SIGN_DIR:-default}" in
  run_deploy() greift nur, wenn die Variable NICHT gesetzt ist, und genau die
  ist es, mit der man auf ~/workspace/fw-sign (das ECHTE Schluesselverzeichnis
  auf diesem Rechner) zeigt. run_deploy() setzt NINLIGHT_SIGN_DIR jetzt hart
  und liest dafuer nur noch die eigene Testvariable TEST_SIGN_DIR, die
  deploy.sh nicht kennt und die deshalb nie von aussen vorbelegt sein kann;
  am Dateikopf werden zur Sicherheit beide Variablen ge-unset. Verifiziert:
  die Suite bleibt gruen, wenn NINLIGHT_SIGN_DIR vorher exportiert ist wie in
  einem echten Entwicklerprofil.

- Szenario 15 vergleicht jetzt ALLE Zeilen beider privater Testschluessel
  gegen Ausgabe UND Log, nicht nur eine einzelne Zeile des Produktions-
  schluessels gegen die Ausgabe.

Vier neue/erweiterte Testfaelle (18-20) plus die Ergaenzung an Szenario 14:
Verzeichnis vorhanden aber leer, Voll-Deploy mit nur dev-Schluessel, echter
Server-Leser fuer das Manifest, gezielter Deploy fuehrt das Manifest zusammen
statt es zu verkuerzen. Jeder der vier Critical-Befunde wurde vor dem Fix per
Rot-Probe am kaputten Code bestaetigt.
2026-09-13 20:14:19 +02:00
2026-09-11 12:19:33 +02:00

ninlight

Lokale Firmware für die alten Klyqa-Lampen mit WinnerMicro W600 (E27/E14/GU10 RGB+CW, E27/E14/GU10 CW, G95 Vintage) und das W600-EVK.

Voraussetzungen

  • Docker Desktop (primärer Build-Pfad, erste Ausführung baut das Image ninlight-build)
  • CMake ≥ 3.20, Ninja, host C compiler (für Host-Tests)
  • uv (für die serielle Konsole)
  • ARM GNU Toolchain mit newlib: brew install --cask gcc-arm-embedded (nur Fallback für nativen Build)

Bauen

Docker (empfohlen – kein lokaler ARM-Compiler nötig):

tools/docker.sh cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
tools/docker.sh cmake --build build/evk
tools/docker.sh tools/build_all.sh              # alle 8 Geräte

Für die tägliche Entwicklung: nur das Gerät bauen, das du verwendest (z.B. tools/docker.sh cmake --build build/evk). Der emulierte Docker-Build ist langsam — build_all.sh ist nur für Releases gedacht.

Native Builds (mit lokal installierter ARM-Toolchain):

cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
cmake --build build/evk

Geräte: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw.

Lokale Einstellungen (Token, später WLAN): config.local.cmake.example → config.local.cmake.

Tests

Host-basierte Unit-Tests (laufen nativ auf dem Mac):

tools/test_host.sh

Flashen und Konsole

Verfügbare Ports anzeigen:

build/host-tools/wm_tool -l

Firmware flashen (Standard: App-Image):

tools/flash.sh <device> <port>

Das Skript druckt Bitte bei "please manually reset the device" die Reset-Taste drücken. — wenn wm_tool diese Nachricht zeigt, musst du die Reset-Taste am EVK drücken (Baud 460800). Für blank/erased Chips: tools/flash.sh <device> <port> full (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet).

Serielle Konsole (115200 8N1, lokales Echo):

tools/monitor.sh /dev/cu.<port>

Verfügbare Befehle in der Konsole: help.

Netzwerk & REST

  • WLAN fest einbauen: NINLIGHT_WIFI_SSID / NINLIGHT_WIFI_PASSWORD in config.local.cmake (gespeicherte Einstellungen haben Vorrang).

  • Ohne WLAN-Daten (oder nach Werksreset) öffnet die Lampe den AP ninlight-<mac4> (Passwort NINLIGHT_AP_PASSWORD, Default ninlight) und pulsiert warmweiß. Onboarding:

    curl -X PUT http://192.168.4.1:3333/api/v1/system/settings \
         -d '{"type":"wifi","ssid":"MeinWLAN","password":"geheim123"}'
    curl http://192.168.4.1:3333/api/v1/system/info    # wifi.sta_ip / wifi.error
    

    Der Aufruf ohne Authorization-Header funktioniert nur im unkonfigurierten AP (noch keine gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich. Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback).

  • REST auf Port 3333, Präfix /api/v1: GET|POST device/state, GET system/info, GET|PUT system/settings und PUT system/command (beide mit Header Authorization: <token>, Default-Token aabbccddeeff0011223344).

  • Beispiel: curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'

  • Suchen: dns-sd -B _ninlight._tcp (macOS), Details dns-sd -L ninlight-681f _ninlight._tcp.

  • Bruno: bruno/ninlight.yml importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.

  • Konsole: net zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.

Firmware-Update

Der Build erzeugt pro Gerät zwei Images:

  • _gz.img — komprimiertes App-Image für das serielle Flashen mit tools/flash.sh (siehe oben).
  • _ota.img — dasselbe _gz.img, um einen 512-Byte-Trailer ergänzt (tools/mkimage/nl_mkimage.py, automatisch bei jedem Build via cmake/run_mkimage.cmake). Der Trailer enthält Gerätetyp, Firmware-Version und den SHA256 über das komplette _gz.img — das ist genau der Hash, den die Firmware beim Schreiben ins Flash prüft.

Netz-Update über die REST-API:

curl -X PUT http://ninlight-681f.local:3333/api/v1/system/command \
     -H 'Authorization: aabbccddeeff0011223344' \
     -d '{"command":{"type":"fw_update","url":"http://<host-ip>:8000/pfad/zur/ninlight_evk_ota.img"}}'

Antwort 202 {"status":"updating"} startet den Download im Hintergrund; ein per HTTP-Server bereitgestelltes _ota.img reicht (z.B. python3 -m http.server 8000). Eine ungültige URL (kein http://-Präfix, oder zu kurz/lang) beantwortet die API sofort mit 400, ohne dass ein Download versucht wird. Läuft bereits ein Update, oder scheitert der Start aus einem anderen Grund, antwortet sie mit 409 und dem Grund im Body (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 endet bei last_ota_error = download. Die Firmware kann einen 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):

Wert Bedeutung
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 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.

Recovery

Findet die Firmware beim Booten keinen Weg, sich selbst zu aktualisieren (z.B. weil das reguläre WLAN nicht erreichbar ist), sucht sie kurz nach einem Rettungs-Zugangspunkt: SSID/Passwort sind Build-Parameter (NINLIGHT_RECOVERY_SSID/NINLIGHT_RECOVERY_PASSWORD in config.local.cmake, Default für beide ninlight-recovery). Wird die Station gefunden, verbindet sich die Lampe und lädt http://192.168.8.1/ninlight_<device>_ota.img (z.B. ninlight_evk_ota.img) — der Dateiname enthält den Gerätetyp, <device> ist der Wert von NINLIGHT_DEVICE aus dem Build.

Bei jedem Boot scannt die Firmware dafür bis zu ~3 Sekunden aktiv (tls_wifi_scan(), kein passiver Scan) nach der Station und loggt das Ergebnis (gefunden, nichts gefunden, oder Scan fehlgeschlagen). Ist keine Station in Reichweite, verlängert sich der Boot nur um diese Scan-Zeit; danach startet der normale WLAN-Aufbau wie gewohnt. Über dem Download selbst liegt keine harte Zeitobergrenze — ein langsamer, aber weiter laufender Transfer füttert den Watchdog über den SDK-eigenen Update-Task mit und darf beliebig lange dauern. Erst ein echter Stillstand (die Gegenstelle bleibt verbunden, sendet aber keine Daten mehr, oder eine interne Allokations-Warteschleife hängt) lässt den Watchdog nach 10 Minuten auslaufen; der Chip startet dann neu, statt unbeobachtet zu hängen, und der nächste Boot versucht Recovery erneut.

Vertrauensgrenze: Der Trailer prüft Gerätetyp und SHA256 — das ist kein kryptografisches Signaturverfahren. Der Download läuft über http:// ohne TLS, und der Recovery-Kanal ist ausschließlich durch das oben genannte WLAN-Passwort geschützt, das mit einem öffentlichen Default im Repo steht. Wer im selben WLAN ist und das API-Token kennt (bzw. beim Recovery in Funkreichweite ist und das Passwort kennt), kann der Lampe beliebige Firmware unterschieben. Das ist eine bewusste Entscheidung dieses Projekts: Ziel ist, die Signaturprüfung der alten, abgekündigten Original-Firmware zu umgehen, damit die Lampen überhaupt weiter nutzbar bleiben — nicht, ein neues, gehärtetes Sicherheitsmodell zu bauen.

Werksreset

Gerät 5× hintereinander einschalten, jeweils kürzer als 5 Sekunden.

Hardwaretests

Manuelle Checkliste: docs/hardware-checklist.md.

S
Description
No description provided
Readme
3.7 MiB
2026-10-02 21:43:38 +02:00
Languages
C 84%
Python 5.8%
Shell 5.2%
C# 4.2%
CMake 0.4%
Other 0.2%