Files
ninlight-w600/README.md
T

257 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <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). 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 <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).
**Alternative beim Einrichten:** Statt ein WLAN anzugeben, kann man die Lampe im selben Schritt
dauerhaft auf ihren eigenen Zugangspunkt festlegen — dann braucht sie überhaupt keinen Router.
Im unkonfigurierten AP geht das wie das WLAN-Onboarding ohne Token:
curl -X PUT http://192.168.4.1:3333/api/v1/system/settings \
-d '{"type":"standalone","enabled":true,"password":"geheim123"}'
Einzelheiten unter [Standalone-AP](#standalone-ap).
- 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: <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.
### sACN / E1.31
Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf
`239.255.<universe_hi>.<universe_lo>` 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://<lampe>:3333/api/v1/system/settings \
-H 'Authorization: <token>' \
-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 `<hostname>.local`.
Einschalten:
curl -X PUT http://<lampe>:3333/api/v1/system/settings \
-H 'Authorization: <token>' \
-d '{"type":"standalone","enabled":true,"ssid":"Buehne","password":"geheim123","channel":6}'
Antwort `202 {"status":"standalone"}`. Der Aufruf braucht den Token, sobald die Lampe konfiguriert
ist; im unkonfigurierten Onboarding-AP geht er ohne. Anders als dieser ist der Standalone-AP
anschließend **nicht** token-frei: der Tokenschutz gilt dort wie im Normalbetrieb. Am Gerät
gemessen (2026-10-02, EVK und eine E27 RGB): `GET system/settings` ohne Token → 401 im
Standalone-AP, 200 im unkonfigurierten Onboarding-AP. 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-<mac4>` 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-<mac4>`.
`GET system/settings` zeigt einen `standalone`-Block mit `enabled`, dem **gewünschten** AP-Namen
(`ssid`: der gespeicherte Name, ohne eigenen Namen der Hostname), `channel` und `pulse` — das
Passwort steht nie darin. `GET system/info` meldet `wifi.standalone` und im AP-Betrieb (`mode: "ap"`,
also eigener AP oder Onboarding-AP) unter `wifi.ssid` den **laufenden AP-Namen** statt der
gespeicherten Stations-SSID — der Name des Heimnetzes steht damit nicht in einer tokenfrei lesbaren
Antwort.
Die beiden Namen können auseinanderlaufen, und das ist Absicht. Scheitert der AP-Aufbau mehrfach,
fällt der Treiber auf die zuletzt funktionierenden Daten zurück, und gibt es die nicht, auf Name und
Kanal der Vorgaben (das Nutzerpasswort bleibt); die gespeicherte Einstellung behält dabei den
Wunschnamen. Nach `{"enabled":false}` behält sie `ap_ssid` ebenfalls, der laufende AP heißt dann
wieder `ninlight-<mac4>`. Genau diese Abweichung zwischen `system/settings` (gewünscht) und
`system/info` (läuft) ist das Erkennungsmerkmal für einen Rückfall.
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://<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`.