# 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, ledshield_rgb (Montana Black LED Shield, nur RGB). **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 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). Das Skript ruft `wm_tool` mit `-rs none` auf, es löst also **keinen** Reset aus: Der Reset muss von Hand in dem Zeitfenster kommen, in dem `wm_tool` auf den Sync wartet. Ein automatischer Reset über die Steuerleitungen (`-rs rts` o. ä.) funktioniert an diesem Board nicht. Für blank/erased Chips: `tools/flash.sh full` (flasht stattdessen das Vollbild mit secboot, auf Hardware noch ungetestet). Serielle Konsole (115200 8N1, lokales Echo): tools/monitor.sh /dev/cu. 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-` (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` (kennt u.a. die Typen `sacn` und `standalone`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: `, 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. ### sACN / E1.31 Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf `239.255..` und per Unicast direkt an ihre IP. Nach einem Update ist der Empfang aktiv: Universe 1, Startslot 1, Belegung `rgb`. Es gibt zwei Slot-Belegungen, umschaltbar über `layout`: | `layout` | Slots ab `start_slot` | wofür | |---|---|---| | `dimmer` | Dimmer, R, G, B, CW, WW — ohne RGB: Dimmer, CW, WW | DMX-Pulte, die einen Master-Dimmer erwarten | | `rgb` (Vorgabe) | R, G, B (3 Slots auf jedem Board) | pixelorientierte Werkzeuge wie LedFx, die je Pixel drei Kanäle schreiben | Im `rgb`-Layout ist jede Lampe genau ein Pixel: Lampe 1 auf Offset 0, Lampe 2 auf Offset 3, und so weiter. Ein Board ohne Farbkanäle nimmt den größten der drei Werte auf beide Weißkanäle — es folgt damit der Helligkeit des Effekts. Wieviele es sind, steht als `slots` in `GET system/settings`. Während Pakete fließen, hat sACN Vorrang; die Werte gehen ohne Fade direkt an die Kanäle, und der per API gesetzte Zustand bleibt unangetastet. 2,5 s nach dem letzten Paket endet die Übernahme — je nach `on_timeout` fällt die Lampe auf den per API gesetzten Zustand zurück (`release`, Vorgabe) oder behält die letzten Werte (`hold`). Ein Befehl an `device/state` holt die Kontrolle in beiden Fällen sofort zurück. Umstellen: curl -X PUT http://:3333/api/v1/system/settings \ -H 'Authorization: ' \ -d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}' Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`. Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`. ### Standalone-AP Statt sich in ein WLAN einzuwählen, kann die Lampe dauerhaft einen eigenen Zugangspunkt aufspannen (z. B. für Bühne oder Gartenhaus ohne Router). Der Modus überlebt Neustarts: die Lampe startet direkt im eigenen AP, ohne Onboarding und ohne Onboarding-Pulsen. Die Lampe ist dann unter `192.168.4.1` erreichbar, per mDNS als `.local`. Einschalten: curl -X PUT http://:3333/api/v1/system/settings \ -H 'Authorization: ' \ -d '{"type":"standalone","enabled":true,"ssid":"Buehne","password":"geheim123","channel":6}' Antwort `202 {"status":"standalone"}`. Anders als der unkonfigurierte Onboarding-AP ist der Standalone-AP nicht token-frei: der Tokenschutz gilt dort wie im Normalbetrieb (am Gerät noch nicht nachgemessen, siehe Abnahmeliste). Felder: | Feld | Pflicht | Bedeutung | |---|---|---| | `enabled` | ja | `true` schaltet den Modus ein, `false` aus | | `password` | beim Einschalten, solange noch keines gespeichert ist | AP-Passwort, 8..63 Zeichen | | `ssid` | nein | AP-Name, 1..32 Zeichen; ein leerer String wird mit 400 `invalid ssid` abgelehnt. Weggelassen bleibt der gespeicherte Name erhalten; `ninlight-` gilt nur, solange nie ein eigener gesetzt wurde — über die API führt kein Weg zurück zum Vorgabenamen | | `channel` | nein | Funkkanal 1..13, Vorgabe 6 | | `pulse` | nein | Startpuls beim Hochfahren der Lampe an/aus, Vorgabe an (läuft rund 5 s, danach gilt das normale Einschaltverhalten) | Ausschalten braucht kein Passwort: `{"type":"standalone","enabled":false}`. Hat die Lampe gespeicherte WLAN-Daten, wählt sie sich damit wieder ins WLAN ein; ohne solche öffnet sie den unkonfigurierten Onboarding-AP `ninlight-`. `GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **wirksamen** AP-Namen (`ssid`), `channel` und `pulse` — das Passwort steht nie darin. `GET system/info` meldet `wifi.standalone`. Der Startpuls übermalt als Überlagerung die sACN-Ausgabe: solange er läuft, ist von sACN nichts zu sehen. Beim Ausprobieren von sACN über den eigenen AP den Puls mit `pulse: false` abschalten oder abwarten. Außerdem muss der Sender bei zwei Default-Routen (LAN und AP) das Interface für Multicast festlegen (`IP_MULTICAST_IF`), sonst sendet er ins eigene LAN statt in den AP. ## 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://: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__ota.img` (z.B. `ninlight_evk_ota.img`) — der Dateiname enthält den Gerätetyp, `` 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`.