Viertes Analog-Profil neben den SM2135EH-Lampen: PWM-Kanaele R=PB13 (ch1),
G=PB16 (ch2), B=PB15 (ch3) bei 800 Hz, PIN-belegt aus der Original-Firmware
(qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD). Kein CW/WW-Zweig:
ct_min == ct_max ist das Erkennungszeichen fuer 'weiss = RGB-Mischung',
nl_color.c gibt in diesem Fall alle drei Farbkanalgleich.
Neues Bauziel in allen Skripten; test_release zaehlt Dateizahlen jetzt aus
TARGETS statt hart 8/16.
Pixelorientierte Werkzeuge wie LedFx schreiben drei Kanaele je Pixel; mit
dem Dimmer voran landet deren erstes Pixel auf Dimmer, R, G. rgb ist damit
die Belegung, die out of the box das Richtige tut - der Dimmer bleibt fuer
DMX-Pulte eine Umschaltung entfernt.
Die Lampe trat seit jeher der falschen Gruppe bei. Aus Universe 1 wurde
239.255.239.255 statt 239.255.0.1, und damit lauschte sie auf einer Adresse,
auf die niemand sendet: Multicast kam nie an, Unicast lief, und der Beitritt
meldete trotzdem Erfolg - er gelang ja, nur eben fuer die falsche Gruppe.
Ursache ist ein nicht geklammertes Makro im SDK:
#define htonl(n) (((n & 0xff) << 24) | ((n & 0xff00) << 8) | ...)
Stand die Adressrechnung direkt im Aufruf, traf die Maske nach der
Textersetzung nur den letzten Term, weil & staerker bindet als |. Nachgerechnet
ergibt das fuer Universe 1 exakt die 0xefffefff, die am EVK auf der seriellen
Konsole stand.
Die Rechnung liegt jetzt in nl_e131_group_addr() - im reinen, host-testbaren
Teil, mit Testfaellen fuer beide Byte-Haelften. Der Aufrufer reicht eine
Variable an htonl(), keinen Ausdruck mehr.
Gefunden am EVK ueber die serielle Konsole, nachdem vier Netz-Konfigurationen
und fuenf andere Theorien nichts ergeben hatten.
LedFx und Verwandte denken in RGB-Pixeln: Ihr e131-Geraet schreibt je Pixel
genau drei Kanaele. Unsere Belegung beginnt mit einem Master-Dimmer, also landet
deren erstes Pixel auf Dimmer, R, G - ein rotes Effektbild (255,0,0) hiesse bei
uns "Dimmer 255, Rot 0, Gruen 0" und damit Dunkelheit. Von Hand eintragen liess
sich eine Lampe schon, sie zeigte aber Unsinn.
Der Dimmer bleibt fuer DMX-Pulte richtig, deshalb eine zweite Belegung statt
einer anderen: layout = "dimmer" (Vorgabe, unveraendert) oder "rgb" (R,G,B, drei
Slots auf jedem Board). Im rgb-Layout ist eine Lampe genau ein Pixel.
Ein Board ohne Farbkanaele bekommt trotzdem ein Farbtripel. Genommen wird der
groesste der drei Werte auf beide Weisskanaele: die Lampe folgt sichtbar der
Helligkeit des Effekts, ohne dass ein Farbmodell in den DMX-Pfad kommt - genau
das wird dort sonst bewusst vermieden.
Die Obergrenze fuer start_slot haengt jetzt am Layout (512 - slots + 1). Ein
Wechsel, der einen bisher zulaessigen start_slot unzulaessig macht, wird
abgelehnt statt still verschoben: eine Lampe, die nach einer Einstellung
woanders im Universe liegt als angegeben, findet niemand wieder.
Das neue Settings-Feld beginnt bei Offset 216, dem sizeof davor, und ist durch
eine eigene Pruefung zur Uebersetzungszeit und einen Migrationstest abgesichert -
derselbe Fallstrick, der beim Schlussreview fuer sacn_enabled gefunden wurde.
Bei der Abnahme kam Multicast nicht an, waehrend Unicast lief. Der Rueckgabewert
von IP_ADD_MEMBERSHIP wurde bis jetzt verworfen - absichtlich, weil ein
gescheiterter Beitritt den Unicast-Empfang nicht verhindern soll. Die Folge war
aber, dass sich die entscheidende Frage nicht beantworten liess: tritt die Lampe
nicht bei, oder reicht das WLAN die Gruppe nicht weiter?
system/info traegt deshalb jetzt "sacn.multicast", und der Beitritt schreibt beim
Socket-Aufbau eine Zeile auf die Konsole. Am Verhalten aendert sich nichts: ein
Fehlschlag bleibt folgenlos fuer Unicast.
Ein Datensatz aus der Firmware vor diesem Zweig ist 208 Byte lang und traegt hinter
wake_on_change (206) noch ein Fuellbyte. sacn_enabled lag auf genau diesem Byte (207),
und read_rec() kopiert hdr.length Bytes ueber die Vorgaben - jede Lampe im Feld waere
nach der OTA mit sacn_enabled == 0 hochgekommen und haette keinen Socket gebunden,
genau das Gegenteil dessen, was Spezifikation und README versprechen.
_legacy_pad schiebt das erste neue Feld auf 208; _padding schrumpft auf 1 Byte, damit
sizeof ein Vielfaches von 4 bleibt (216). nl_settings_legacy_check pinnt den Offset zur
Uebersetzungszeit fest, denn die bisherige Pruefung sah nur die Teilbarkeit durch 4 und
liess diesen Fall durch. Der Test kuerzte auf offsetof(sacn_enabled) - eine Laenge, die
es auf keiner Lampe je gab - und ging deshalb durch; er kuerzt jetzt auf die Literalzahl
208 und stellt das Fuellbyte nach.
Universe 0 and values >63999 are reserved per E1.31 spec and must be rejected.
Priority is deliberately not validated (compared relatively in later tasks only).
Test coverage:
- test_universe_zero_rejected: rejects universe 0
- test_universe_too_high_rejected: rejects universe 64000
- test_universe_is_read: continues to accept universe 63999
Die vier reinen Weiss-Lampen standen flach auf 2500-6500 K, was fuer keine
von ihnen stimmte. Nach Michaels Produktdaten:
- E27 CW/WW, E14 CW/WW, GU10 CW/WW: 2700-6500 K (vorher 2500-5000)
- G95 Vintage: 2000-5000 K (vorher 2500-5000)
Die drei erstgenannten haben damit oben 1500 K verschenkt, die G95 unten
500 K. Die RGB-Varianten und das EVK standen schon richtig auf 2700-6500.
test_board.c prueft die Bereiche jetzt pro Board statt ueber die Faustregel
"RGB oder nicht" - die G95 ist die Ausnahme, und eine Faustregel, die eine
Ausnahme hat, ist keine. Die beiden Clamp-Tests in test_boot_state.c lesen
die Obergrenze jetzt vom Board, statt die Zahl zu wiederholen: sie pruefen
das Begrenzen, nicht die Konstante.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei neue Einstellungen, beide wie fade_in/fade_out ueber
POST /api/v1/device/state les- und schreibbar und sofort wirksam:
- transition_default (Voreinstellung 300 ms) fuellt die Transition fuer
Farb-, Weiss- und Helligkeitswechsel bei eingeschalteter Lampe, wenn der
Aufruf kein eigenes "transition" mitbringt. Beim Ein- und Ausschalten
behalten fade_in/fade_out weiterhin Vorrang.
- wake_on_change (Voreinstellung aus) schaltet die Lampe bei einer Farb-,
Weiss- oder Helligkeitsaenderung ein, wenn sie aus war. Ein
ausdrueckliches "on": false im selben Aufruf gewinnt immer.
Beide Felder haengen am Ende von nl_settings_t, damit bestehende Lampen
ueber die Laengentoleranz von read_rec() ihre echten Voreinstellungen
bekommen statt Nullen. nl_ctrl_set_fades() heisst jetzt
nl_ctrl_set_options().
Dazu ein Treiberfehler, der erst an der migrierten GU10 RGB auffiel: der
WY-Zweig von nl_sm2135eh_set() schrieb nur Header und WY-Daten, nie die
RGB-Datenregister. Der Chip behielt also die zuletzt gesetzte Farbe, und
bei einem direkten Wechsel Farbe -> Weiss (ohne Zwischenschritt ueber
dunkel) schaelte sie sich beim Ausblenden wieder frei. Der Header sendet
jetzt die - in Weiss immer nullwertigen - RGB-Daten mit.
Die Bruno-Collection zeigt Transition jetzt zusammen mit einem Farb- und
einem Weisswechsel im selben Aufruf und hat einen Request fuer die
beiden neuen Einstellungen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Antwort gab bisher nur die gespeicherte SSID aus. Wurde eine Lampe nie
per REST onboardet, ist die leer, und die Lampe laeuft in Wirklichkeit auf
den einkompilierten NINLIGHT_WIFI_SSID/-PASSWORD (siehe app_main.c) -
GET system/settings suggerierte damit "kein WLAN konfiguriert", obwohl die
Lampe zuverlaessig verbunden ist, und widersprach scheinbar system/info.
Additiv, ohne die Bedeutung von "ssid" zu aendern: wifi.source ist jetzt
"stored" oder "compiled". Spec (§7.2) und bruno/ninlight.yml dokumentieren
das Feld, ein neuer Hosttest deckt den compiled-Fall ab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
nl_settings_save() rechnete die CRC über [0, offsetof(rec_t, crc)) und legte sie
dort ab, read_rec() liest sie bei sizeof(rec_hdr_t) + hdr.length. Beide Offsets
stimmen nur überein, solange sizeof(nl_settings_t) ein Vielfaches von 4 ist
(aktuell 204). Ein zusätzliches uint16_t - in Plan 4 (Migration) sehr
wahrscheinlich - macht 206, und jeder gespeicherte Record fiele beim nächsten
Boot still durch die CRC: WLAN, Token und Name wären auf allen Lampen weg.
Geschrieben wird jetzt explizit bei NL_SETTINGS_CRC_OFF(length), also exakt an
der Leseposition; dazu eine Übersetzungszeit-Zusicherung, dass die Nutzdaten
direkt hinter dem Header beginnen (die CRC läuft auf beiden Seiten über einen
zusammenhängenden Block).
Test: test_crc_offset_matches_reader prüft die Symmetrie unabhängig von der
Strukturgröße. Gegenprobe mit einem zusätzlichen uint16_t im Struct: mit der
alten Schreibposition fallen vier Settings-Tests, mit der neuen keiner.
Spec 8.2 verlangt, dass ein durch ein Update ausgelöster Neustart nicht wie ein
Stromausfall wirkt: Das Flag ota_pending wurde bisher nur geschrieben und beim
nächsten Boot wieder gelöscht, nl_boot_state() wendet aber weiterhin das normale
power_on_behaviour an. Eine eingeschaltete Lampe mit pob=off kam nach einem Update
aus zurück, mit pob=on umgekehrt.
nl_boot_state() bekommt dafür ein Argument after_ota; ist ein gespeicherter
Zustand vorhanden, wird er unverändert übernommen. Ohne gespeicherten Zustand
gelten weiterhin die normalen Regeln.
Hosttests: vier neue Fälle in test_boot_state.c (pob=off/on über ein OTA,
Sanitisierung bleibt aktiv, kein gespeicherter Zustand).
read_rec() füllte den Puffer bisher mit memset(0) statt mit
nl_settings_defaults() vor, bevor die tatsächlich vorhandenen hdr.length
Bytes darübergeschrieben werden. Ein gekürzter Record, dessen Länge vor
fade_in_ms/fade_out_ms endet, aber token noch enthält, lief damit mit
fade_in_ms=0/fade_out_ms=0 statt der spezifizierten 500 ms - genau der Fall,
für den die Längentoleranz gedacht war. Die davor eingebaute "leerer Record"
Sonderbehandlung in nl_settings_load() (fade_in_ms==0 && fade_out_ms==0 &&
token[0]=='\0' -> volle Defaults) ist dadurch überflüssig geworden und
entfernt.
test_load_accepts_shorter_record kürzt jetzt bis vor fade_in_ms und prüft
explizit auf die echten Defaults (500/500/0) statt nur auf Felder vor dem
gekürzten Bereich. test_load_rejects_longer_record verzichtet auf die tote
CRC-Neuberechnung, da read_rec() zu lange Records schon anhand der Länge
verwirft, bevor Payload oder CRC gelesen werden.
Das Feld lieferte ohnehin nur den festen String "unknown" - kein SDK-Aufruf
lieferte je einen echten Reset-Grund. README/hardware-checklist erwähnten
es nicht.
read_rec() akzeptiert jetzt Records mit hdr.length <= sizeof(nl_settings_t):
CRC wird nur über die tatsächlich geschriebenen Bytes geprüft, fehlende Felder
bleiben auf 0 (Default). Ein zu langer (unbekannter) Record wird weiter
abgelehnt. Ein durch massive Kürzung "leer" gelesener Record (fade_in_ms,
fade_out_ms und token allesamt 0) fällt komplett auf die Defaults zurück.
Neues Feld ota_pending (nach fade_out_ms) für den kommenden OTA-Ablauf.
M2: WLAN-Passwort muss leer (offenes Netz) oder 8..64 Zeichen lang sein
(400 "invalid password" bei 1..7 Zeichen). CMake prüft NINLIGHT_WIFI_PASSWORD
ebenso (0 oder 8..63 Zeichen) und der Zeichen-Blacklist-Check bekommt `;`
dazu.
M4: Ein Authorization-Wert, der länger als NL_TOKEN_LEN-1 (64) ist, wird
von nl_http_parse() nicht mehr stillschweigend abgeschnitten, sondern setzt
`auth_too_long` in nl_http_request_t; nl_api_handle() behandelt eine solche
Anfrage als unauthentifiziert (außer bei offenem Zugriff), damit ein
abgeschnittener Wert nie zufällig auf ein kürzeres Token passt.
M5: get_int() prüfte die (long)-Cast-Gleichheit vor dem Bereichscheck -
bei sehr großen oder NaN-Werten ist der Cast von double nach long
undefiniertes Verhalten. Bereichs- und NaN-Check laufen jetzt vor dem Cast.
T1: sdk/Platform/Drivers/internalflash/wm_internal_fls.c: tls_fls_init()
ignorierte den Rückgabewert von tls_os_sem_create(&s_fls2_lock, 1) - jetzt
eine TLS_DBGPRT_ERR-Meldung, falls das fehlschlägt (dokumentiert in
sdk/PATCHES.md).
Tests: test_api.c (Passwortlängen, auth_too_long, 1e300-Brightness),
test_http.c (auth_too_long wird gesetzt). Für M5 zeigt der Host-Test kein
sichtbares RED, weil der (double)->(long)-Cast auf dieser Plattform/diesem
Compiler keinen Crash auslöst (implementation-defined statt UB-Crash) - der
Fix wird trotzdem angewendet, da er auf dem Zielcompiler (arm-none-eabi 4.9)
nicht garantiert sicher ist.
I1: fail_test() kehrte ohne gespeicherte Zugangsdaten stillschweigend nach
AP_ONBOARDING zurück, ohne den Stationsversuch zu trennen; ein danach doch
noch eintreffendes CONNECTED wurde in AP_ONBOARDING ignoriert, aber der
Treiber rief nl_mdns_on_ip_up() unabhängig vom Zustand auf - die Lampe
landete offen im LAN, ohne dass Zugangsdaten gespeichert wurden.
nl_wifi_actions_t bekommt ein neues Feld `disconnect`:
- fail_test() ohne Zugangsdaten setzt es (Timeout/Fehler beendet den
Stationsversuch).
- Ein CONNECTED-Event in AP_ONBOARDING gilt als Ausreißer eines bereits
gescheiterten Versuchs und wird ebenfalls getrennt (Zustand bleibt
unverändert).
- M1: nl_wifi_sm_onboard() trennt jetzt immer vor dem Testverbindungsaufbau,
damit veraltete Events eines vorherigen Versuchs nicht mehr zählen; der
Treiber (nl_wifi_request_onboard) meldet zusätzlich "wifi busy" (503),
solange bereits ein Onboarding-Test läuft.
Der Treiber (src/net/nl_wifi_w600.c) führt `disconnect` vor `connect` aus
und ruft nl_mdns_on_ip_up() nur noch, wenn der Zustand nach der Verarbeitung
tatsächlich STA_CONNECTED ist.
Tests (test_wifi_sm.c): Timeout/Fehler ohne Zugangsdaten -> disconnect;
CONNECTED während AP_ONBOARDING -> disconnect, Zustand unverändert;
erneutes Onboarding während laufendem Test -> disconnect vor dem neuen
connect.
C1: Ein POST an /api/v1/device/state mit tief verschachteltem JSON (z.B.
20 Ebenen "[[[[...") lässt cJSONs rekursiven Parser den 6-KB-Stack des
HTTP-Tasks überlaufen - CJSON_NESTING_LIMIT war mit 1000 viel zu hoch und
FreeRTOS' Stack-Overflow-Checking ist aus. Begrenzt auf 8 Ebenen, sowohl
für die Firmware (third_party/cJSON/cJSON.c) als auch für die
Host-Tests (nl_cjson), damit beide dasselbe Verhalten zeigen.
Test: test_post_validation deckt jetzt einen Body mit 20 verschachtelten
Arrays ab (400 "invalid json").
- vApplicationIdleHook() füttert den Watchdog nicht mehr (sdk/Platform/Sys/wm_main.c,
dokumentiert als Patch 4 in sdk/PATCHES.md). Bisher fütterte der Idle-Task den
Watchdog bei jedem Leerlauf, auch wenn ein Task deadlockt (z.B. nl_app_lock() hält
und nie freigibt) - der Watchdog konnte so nie greifen.
- Neues Konsolenkommando `wdtest` (Parser in nl_console_cmd.c/.h, Ausführung in
nl_console.c, Host-Test in test_console_cmd.c): hält nl_app_lock() für immer, damit
light_task und app_task blockieren und nur der Watchdog-Reset den Chip retten kann.
- nl_app_request_factory_reset() (nl_app.h) setzt nur ein volatile Flag; app_task
prüft es in der Hauptschleife und ruft nl_app_factory_reset() selbst auf, da nur der
App-Task die Flash-Stores besitzt. Das Konsolenkommando `factory` ruft jetzt diese
Funktion statt nl_app_factory_reset() direkt aufzurufen, um die Race mit
nl_state_log_append()/nl_resetcnt_clear() im App-Task zu vermeiden. Der Bootpfad
(Bootzähler >= 5) ruft weiterhin direkt nl_app_factory_reset() auf.
- nl_resetcnt_clear() wird nur noch als erledigt markiert, wenn es 0 zurückgibt, damit
ein fehlgeschlagener Clear in der nächsten Schleifeniteration erneut versucht wird.
Implementiert:
- Flash HAL (nl_flash_read/write/erase_sector) Interfaces
- Flash Layout mit A/B-Settings Slots und weiteren Regions
- Test-Fake für Flash mit Fehlerinjection (Stromausfall-Simulation)
- Settings-Store mit CRC32 Recovery und Slot-Alternierung
- Vollständige Test-Suite mit Corruption/Power-Loss-Tests