Zwei Befunde der Hardware-Abnahme. 1) Die Schrift war zu klein: compose() zeichnete alles in ImageFont.load_default() (rund 10 px) - auf 250x122 aus zwei Metern nicht lesbar. blocks() vergibt jetzt Rollen (header/hero/detail/big), compose() setzt jede Rolle eigen: Modus fett und weiss auf schwarzem Balken, die wichtigste Zeile gross (Migration: erkannte Lampe, Rettung: SSID), Beiwerk klein, "Station aus" gross und mittig. Die Schrift kommt per ImageFont.truetype() aus FONT_SEARCH_PATHS mit DejaVu (/usr/share/fonts/truetype/dejavu/) als Hauptweg - auf beiden Pis vorhanden. load_default(size=...) scheidet aus: das gibt es erst ab Pillow 10.1, Pi 2 hat 9.4.0. Letzter Rueckfall bleibt load_default() ohne Argument, damit eine fehlende Schrift die Anzeige (und damit nie eine Migration) aufhaelt. lines() bleibt ohne Pillow importierbar, die Breitenkuerzung misst weiter in Pixeln. 2) "display test" scheiterte bei laufendem Anzeigedienst mit einem rohen "lgpio.error: 'GPIO busy'": lgpio.error ist keine gpiozero.GPIOZeroError und fiel durch die Ausnahmeliste in epdconfig.module_init(). Sie ist jetzt aufgefuehrt - weiterhin gezielt, TypeError/AttributeError/NameError bleiben Programmierfehler - und die Meldung nennt bei belegten Leitungen den Anzeigedienst. "display test|clear" stoppt den Dienst fuer die Dauer des Handgriffs und startet ihn danach wieder, aber nur, wenn er vorher lief.
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_PASSWORDinconfig.local.cmake(gespeicherte Einstellungen haben Vorrang). -
Ohne WLAN-Daten (oder nach Werksreset) öffnet die Lampe den AP
ninlight-<mac4>(PasswortNINLIGHT_AP_PASSWORD, Defaultninlight) 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.errorDer 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/settingsundPUT system/command(beide mit HeaderAuthorization: <token>, Default-Tokenaabbccddeeff0011223344). -
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), Detailsdns-sd -L ninlight-681f _ninlight._tcp. -
Bruno:
bruno/ninlight.ymlimportieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen. -
Konsole:
netzeigt 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 mittools/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 viacmake/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_TIMEOUTvon 5 s des HTTP-Clients greift wie vorgesehen, die Lampe gibt die Verbindung auf, baut eine neue auf und setzt perRangegenau 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_TIMEOUTder 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 weiterenfw_updatemit409ab, 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.