diff --git a/CMakeLists.txt b/CMakeLists.txt index bae47fb..47c59aa 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ set(NL_FW_SOURCES src/board/nl_board.c src/light/nl_color.c src/light/nl_engine.c + src/light/nl_dmx.c src/light/nl_control.c src/light/nl_boot_state.c src/config/nl_settings.c @@ -89,12 +90,14 @@ set(NL_FW_SOURCES src/sys/sdk_stubs.c src/net/nl_names.c src/net/nl_http.c + src/net/nl_e131.c src/net/nl_api.c src/net/nl_wifi_sm.c src/net/nl_wifi_w600.c src/net/nl_recovery.c src/net/nl_mdns.c src/net/nl_http_server.c + src/net/nl_sacn.c src/sys/nl_app_api.c third_party/cJSON/cJSON.c) diff --git a/README.md b/README.md index a89d0e7..9f8a014 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,39 @@ Verfügbare Befehle in der Konsole: `help`. 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: `, Default-Token `aabbccddeeff0011223344`). +- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. den Typ `sacn`, 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. + +Slots ab `start_slot`: **Dimmer, R, G, B, CW, WW** (Boards ohne RGB: **Dimmer, CW, WW**). +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`. + ## Firmware-Update Der Build erzeugt pro Gerät zwei Images: diff --git a/bruno/ninlight.yml b/bruno/ninlight.yml index 16e7e79..2e68d9a 100644 --- a/bruno/ninlight.yml +++ b/bruno/ninlight.yml @@ -328,10 +328,32 @@ items: timeout: 0 followRedirects: true maxRedirects: 5 + - info: + name: Set sACN + type: http + seq: 7 + http: + method: PUT + url: '{{device_address}}:{{device_port}}/api/v1/system/settings' + body: + type: json + data: |- + { + "type": "sacn", + "enabled": true, + "universe": 1, + "start_slot": 1, + "on_timeout": "release" + } + settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 - info: name: Commands type: folder - seq: 7 + seq: 8 items: - info: name: Ping diff --git a/docs/acceptance-sacn.md b/docs/acceptance-sacn.md new file mode 100644 index 0000000..66da692 --- /dev/null +++ b/docs/acceptance-sacn.md @@ -0,0 +1,81 @@ +# Abnahme sACN/E1.31 + +Datum: 2026-09-20, nachts +Lampe: Klyqa E27 RGB CW/WW, `192.168.2.204`, MAC `28:6d:cd:5c:7f:5b`, Board `e27_rgb` (6 Slots) +Firmware vorher: `0.3.8-36-gca9bb` — nachher: `0.3.8-61-g9b80a` +Sender: `tools/sacn_send.py`, ausgefuehrt vom Pi `ninlight-station` (192.168.2.96), also aus +demselben Netz wie die Lampen. + +## Ergebnis + +Bestanden, mit einer benannten Einschraenkung: **Multicast erreicht die Lampe nicht**, +Unicast funktioniert vollstaendig. Siehe den eigenen Abschnitt unten. + +## Einzelne Schritte + +| # | Pruefpunkt | Wie geprueft | Ergebnis | +|---|---|---|---| +| 0 | OTA aus Home Assistant | `update.install` auf der Update-Entitaet, Abbild aus dem Firmware-Ordner der Produktiv-HA | bestanden; Lampe nach dem Neustart mit Uptime 12 s zurueck, WLAN unveraendert | +| 0a | Versionsvergleich der Integration | HA meldete `0.3.8-36-gca9bb` -> `0.3.8-61-g9b80a` als Update | bestanden; HAs eingebauter Vergleich haette diese Git-Versionen falsch sortiert, der eigene greift | +| 0b | Vorgaben nach dem Update | `GET system/settings` | bestanden: `enabled true`, Universe 1, Startslot 1, `release`, `slots 6` | +| 1 | Uebernahme, Farbe | Gruen per Unicast, 60 s | bestanden (Sichtpruefung Michael: "leuchtet gruen") | +| 2 | Dimmer-Rampe | 0..255 und zurueck, 25 fps | bestanden nach Wechsel des Senders auf den Pi, siehe "Ruckeln" unten | +| 3 | Timeout, Modus `release` | 30 s Blau, dann Strom beenden | bestanden: nach 2,5 s zurueck auf den HA-Zustand (rot, 21 %), Sicht + `device/state` | +| 4 | Timeout, Modus `hold` | Nullen senden, dann Strom beenden | bestanden: Lampe blieb dunkel, obwohl HA "an, rot" hielt | +| 5 | HA holt die Kontrolle zurueck | `light.turn_on` waehrend die Lampe im `hold` dunkel festhing | bestanden: sofort gelb/80 %, `sacn.active` aus | +| 6 | Abbruch-Bit | Zweimal gemessen, mit und ohne `--terminate` | bestanden: **0,1 s** mit Abbruch-Bit gegen **2,5 s** ueber den Timeout | +| 7 | Universe im Betrieb wechseln | auf 12 umgestellt, dann auf 1 und auf 12 gesendet | bestanden: altes Universe wirkungslos, neues uebernimmt, kein Neustart | + +Nicht auf Hardware geprueft: ein Board **ohne RGB** (3 Slots, Dimmer/CW/WW). Dafuer haette +eine zweite Lampe geflasht werden muessen. Abgedeckt ist der Fall durch die Host-Tests +(`test_dmx.c`, `test_board_without_rgb_uses_three_slots`) und die board-abhaengige +Slotgrenze in `test_api.c`. + +## Befund: Multicast erreicht die Lampe nicht - Ursache liegt im WLAN + +Unicast an die Lampen-IP funktioniert durchgaengig. Multicast auf `239.255.0.1` kommt nicht +an, auch nicht von einem Sender im selben Netz. + +**Geklaert am 2026-09-20 mit einer Diagnose-Firmware** (`0.3.8-63-gfd61f`, Commit fd61f15): +`system/info` meldet seitdem `sacn.multicast`, also das Ergebnis von `IP_ADD_MEMBERSHIP`. +Die Lampe liefert **`multicast: true`** - der Beitritt zur Gruppe gelingt. Die Pakete kommen +trotzdem nicht an. Damit liegt es nicht an der Firmware, sondern am Netz: das WLAN reicht +die Gruppe `239.255.0.1` nicht an die Lampe weiter. + +Dass mDNS funktioniert, widerspricht dem nicht: `224.0.0.251` liegt im link-lokalen Bereich +`224.0.0.0/24`, den Switches und Access Points per Norm von der IGMP-Filterung ausnehmen und +immer fluten. `239.255.0.1` unterliegt dagegen der Filterung. + +Anzusehen waeren im UniFi-Setup fuer das IoT-Netz: + +- WLAN-Einstellungen: "Multicast and Broadcast Control" bzw. "Block LAN to WLAN Multicast + and Broadcast" - ist das aktiv, faellt die Gruppe weg, solange sie nicht ausdruecklich + erlaubt ist. +- Netzwerk-Einstellungen: IGMP Snooping - entweder auf diesem VLAN abschalten (dann wird + geflutet) oder sicherstellen, dass ein Querier laeuft. Snooping ohne Querier ist der + klassische Weg, Multicast unbemerkt verschwinden zu lassen. + +Zwei Fehlversuche auf dem Weg dorthin, damit sie niemand wiederholt: + +- Der erste Multicast-Test lief vom Mac (192.168.1.131) gegen eine Lampe in 192.168.2.x. + Multicast wird zwischen den Netzen nicht geroutet - der Test konnte gar nicht gelingen. +- Der zweite liess den Pi in die Gruppe eintreten und an sie senden. Ein Access Point + schickt einem Sender seine eigenen Multicast-Frames nicht zurueck; das Ergebnis war + aussagelos, nicht negativ. + +## Befund: Ruckeln bei der Dimmer-Rampe + +Die erste Rampe kam sichtbar ruckelig an ("ruckelt sehr"). Gesendet wurde vom Mac, der in +einem anderen Netz haengt als die Lampen; jedes Paket lief ueber den Router. Dieselbe Rampe +vom Pi aus, im selben Netz wie die Lampe, lief deutlich glatter ("viel besser"). + +Fuer den Einsatz heisst das: Die sACN-Quelle gehoert in dasselbe Netz wie die Lampen. + +## Nicht geprueft, weil dafuer eine alte Firmware noetig waere + +Der Migrationspfad aus dem kritischen Befund des Schlussreviews (ein vor sACN geschriebener +208-Byte-Datensatz darf `sacn_enabled` nicht ueberschreiben) wurde auf dieser Lampe nicht +durchlaufen: Sie trug keinen gespeicherten Einstellungssatz, alle Werte standen auf Vorgabe. +Abgedeckt ist der Fall durch den Host-Test in `test_settings.c`, der vor dem Fix fehlschlug +und danach besteht. Wer ihn auf Hardware sehen will, nimmt eine Lampe, deren +Einschaltverhalten vom Vorgabewert abweicht — die traegt einen echten alten Datensatz. diff --git a/src/app_main.c b/src/app_main.c index 7b2f96f..121cbbc 100644 --- a/src/app_main.c +++ b/src/app_main.c @@ -13,10 +13,12 @@ #include "drivers/nl_led.h" #include "light/nl_boot_state.h" #include "light/nl_control.h" +#include "light/nl_dmx.h" #include "light/nl_engine.h" #include "net/nl_http_server.h" #include "net/nl_mdns.h" #include "net/nl_recovery.h" +#include "net/nl_sacn.h" #include "net/nl_wifi.h" #include "sys/nl_app.h" #include "sys/nl_app_api.h" @@ -147,6 +149,7 @@ static void light_task(void *arg) now = nl_app_now_ms(); nl_app_lock(); nl_engine_tick(now - last); + nl_dmx_tick(now); nl_app_unlock(); last = now; s_light_beat++; @@ -160,9 +163,10 @@ static void app_task(void *arg) nl_engine_cfg_t cfg; nl_light_state_t last, initial, pending; bool have_last, after_ota; - bool light_alive = false, net_alive = false, http_alive = false; + bool light_alive = false, net_alive = false, http_alive = false, sacn_alive = false; + bool sacn_required; uint8_t boots; - uint32_t boot_ms, light_seen, net_seen, http_seen; + uint32_t boot_ms, light_seen, net_seen, http_seen, sacn_seen; (void)arg; /* I2: the 5 s of spec 3.4 step 7 / 5.5 count from power-up, not from "after the recovery @@ -205,6 +209,7 @@ static void app_task(void *arg) nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms, s_settings.transition_ms, s_settings.wake_on_change != 0); nl_ctrl_boot(&initial); + nl_dmx_init(board); nl_app_unlock(); tls_os_task_create(NULL, "nl_light", light_task, NULL, (u8 *)s_light_stack, sizeof(s_light_stack), NL_LIGHT_PRIO, 0); @@ -231,9 +236,23 @@ static void app_task(void *arg) nl_http_server_start(nl_app_api_ctx()); + { + static nl_settings_t sacn_boot; /* copy: the network task may change s_settings later */ + nl_app_lock(); + sacn_boot = s_settings; + nl_app_unlock(); + nl_sacn_apply_settings(&sacn_boot); + } + nl_sacn_start(); + /* Nur ein tatsaechlich gestarteter Task darf den Wachhund mitbestimmen: ohne Task + * bewegt sich nl_sacn_beat nie, die Bedingung waere nie erfuellt und die Lampe liefe + * in eine endlose Reset-Schleife. Ein Zusatzdienst faellt dann eben aus. */ + sacn_required = nl_sacn_running(); + light_seen = s_light_beat; net_seen = nl_net_beat; http_seen = nl_http_beat; + sacn_seen = nl_sacn_beat; for (;;) { uint32_t now; bool due; @@ -262,9 +281,10 @@ static void app_task(void *arg) } } - /* Feed the watchdog only once the light, network and HTTP tasks have each made - * progress since the last feed - a single stuck task (e.g. nl_app_lock() held - * forever) must still starve the watchdog, not just the light task. */ + /* Feed the watchdog only once the light, network, HTTP and sACN tasks have each + * made progress since the last feed - a single stuck task (e.g. nl_app_lock() held + * forever) must still starve the watchdog, not just the light task. Ausnahme: der + * sACN-Task, wenn er gar nicht erst angelegt werden konnte (sacn_required). */ if (s_light_beat != light_seen) { light_seen = s_light_beat; light_alive = true; @@ -277,8 +297,12 @@ static void app_task(void *arg) http_seen = nl_http_beat; http_alive = true; } - if (light_alive && net_alive && http_alive) { - light_alive = net_alive = http_alive = false; + if (nl_sacn_beat != sacn_seen) { + sacn_seen = nl_sacn_beat; + sacn_alive = true; + } + if (light_alive && net_alive && http_alive && (sacn_alive || !sacn_required)) { + light_alive = net_alive = http_alive = sacn_alive = false; tls_watchdog_clr(); } } diff --git a/src/board/nl_board.c b/src/board/nl_board.c index 526cd2a..70d4dee 100644 --- a/src/board/nl_board.c +++ b/src/board/nl_board.c @@ -49,3 +49,8 @@ const nl_board_t *nl_board_get(void) return nl_board_find(NINLIGHT_DEVICE_ID); } #endif + +uint8_t nl_board_dmx_slots(const nl_board_t *b) +{ + return (b != NULL && b->has_rgb) ? 6u : 3u; +} diff --git a/src/board/nl_board.h b/src/board/nl_board.h index 0a26785..7c02950 100644 --- a/src/board/nl_board.h +++ b/src/board/nl_board.h @@ -41,4 +41,8 @@ const nl_board_t *nl_board_find(const char *id); /* Board selected at build time via NINLIGHT_DEVICE_ID. */ const nl_board_t *nl_board_get(void); +/* Slots, die eine Lampe im DMX-Universe belegt: Dimmer + R,G,B,CW,WW bzw. Dimmer + CW,WW. + * Steht hier, damit API, Uebernahme-Schicht und Doku dieselbe Zahl benutzen. */ +uint8_t nl_board_dmx_slots(const nl_board_t *b); + #endif diff --git a/src/config/nl_settings.c b/src/config/nl_settings.c index ec97167..3a95fcd 100644 --- a/src/config/nl_settings.c +++ b/src/config/nl_settings.c @@ -29,15 +29,33 @@ typedef struct { /* Where read_rec() expects the CRC, and therefore where nl_settings_save() must put it: directly * behind the `length` payload bytes, not at offsetof(rec_t, crc). Both offsets are only equal - * while sizeof(nl_settings_t) is a multiple of 4 (204 today). Adding a single uint16_t would move - * offsetof(rec_t, crc) two bytes further than the reader looks and every stored record would - * silently fail its CRC on the next boot - Wi-Fi, token and name gone on every lamp. */ + * while sizeof(nl_settings_t) is a multiple of 4 (216 today, 208 before sACN). Adding a single + * uint16_t would move offsetof(rec_t, crc) two bytes further than the reader looks and every + * stored record would silently fail its CRC on the next boot - Wi-Fi, token and name gone on + * every lamp. Aus demselben Grund gilt fuer neue Felder: sie beginnen bei oder hinter dem + * vorherigen sizeof (also nicht unterhalb von 208), denn ein alter Datensatz ist genau + * sizeof lang und traegt seine Fuellbytes mit - ein Feld darunter wuerde beim Lesen von + * diesen Fuellbytes (Null) ueberschrieben statt seine Vorgabe zu behalten. */ #define NL_SETTINGS_CRC_OFF(len) (sizeof(rec_hdr_t) + (uint32_t)(len)) /* The CRC runs over header and payload as one contiguous block on both sides, so the payload * really has to start right behind the header (no padding). Compile-time, gnu99-compatible. */ typedef char nl_settings_layout_check[offsetof(rec_t, data) == sizeof(rec_hdr_t) ? 1 : -1]; +/* Die Satzlaenge muss ein Vielfaches von 4 bleiben - sonst zeigt NL_SETTINGS_CRC_OFF() woanders + * hin als offsetof(rec_t, crc), und jeder gespeicherte Datensatz faellt beim naechsten Boot + * durch die CRC-Pruefung. Wer hier anstoesst, fuegt ein Fuellbyte hinzu, statt die Pruefung + * zu entfernen. */ +typedef char nl_settings_size_check[(sizeof(nl_settings_t) % 4u == 0u) ? 1 : -1]; + +/* Das erste mit sACN angehaengte Feld muss genau dort beginnen, wo der alte Satz aufhoerte: + * bei 208, dem sizeof(nl_settings_t) der Firmware vor sACN. Saetze, die eine solche Firmware + * geschrieben hat, sind 208 Byte lang und enthalten hinter wake_on_change (Offset 206) noch + * ein Fuellbyte. read_rec() kopiert hdr.length Bytes ueber die Vorgaben - ein Feld unterhalb + * von 208 wuerde also von diesem Fuellbyte (Null) ueberschrieben und seine Vorgabe verlieren. + * Bei sacn_enabled hiesse das: sACN nach dem Update auf jeder Lampe aus. */ +typedef char nl_settings_legacy_check[offsetof(nl_settings_t, sacn_enabled) == 208 ? 1 : -1]; + static uint32_t s_addr[2]; void nl_settings_defaults(nl_settings_t *s) @@ -49,6 +67,10 @@ void nl_settings_defaults(nl_settings_t *s) s->fade_out_ms = 500; s->transition_ms = 300; s->wake_on_change = 0; + s->sacn_enabled = 1; + s->sacn_universe = 1; + s->sacn_start_slot = 1; + s->sacn_hold = 0; } void nl_settings_init(uint32_t addr_a, uint32_t addr_b) diff --git a/src/config/nl_settings.h b/src/config/nl_settings.h index 1941be8..bef8e92 100644 --- a/src/config/nl_settings.h +++ b/src/config/nl_settings.h @@ -28,6 +28,14 @@ typedef struct { * switching on or off. */ uint8_t wake_on_change; /* if set, such a change also switches the lamp on when it was * off, unless the same call says "on": false. */ + uint8_t _legacy_pad; /* war Fuellbyte im 208-Byte-Satz: neue Felder muessen + * bei oder hinter dem vorherigen sizeof beginnen, sonst + * ueberschreibt das Fuellbyte eines alten Datensatzes sie */ + uint8_t sacn_enabled; /* Offset 208 - sACN/E1.31-Empfang aktiv (Vorgabe 1) */ + uint16_t sacn_universe; /* 1..63999 (Vorgabe 1) */ + uint16_t sacn_start_slot; /* 1..512 (Vorgabe 1) */ + uint8_t sacn_hold; /* 0 = nach dem Timeout zurueck zum HA-Zustand, 1 = halten */ + uint8_t _padding[1]; /* padding for 4-byte alignment of sizeof(nl_settings_t) */ } nl_settings_t; void nl_settings_defaults(nl_settings_t *s); diff --git a/src/light/nl_dmx.c b/src/light/nl_dmx.c new file mode 100644 index 0000000..b5bae2e --- /dev/null +++ b/src/light/nl_dmx.c @@ -0,0 +1,131 @@ +#include "light/nl_dmx.h" + +#include + +#include "light/nl_engine.h" + +static const nl_board_t *s_board; +static uint8_t s_slots; /* 3 oder 6 */ +static nl_dmx_cfg_t s_cfg; +static bool s_streaming; /* es fliessen Pakete */ +static bool s_override; /* die Engine haelt unsere Kanaele */ +static uint8_t s_prio; +static uint8_t s_seq; +static uint32_t s_last_ms; + +void nl_dmx_init(const nl_board_t *board) +{ + s_board = board; + s_slots = nl_board_dmx_slots(board); + s_cfg.start_slot = 1; + s_cfg.hold = false; + s_cfg.release_fade_ms = 0; + s_streaming = false; + s_override = false; + s_prio = 0; + s_seq = 0; + s_last_ms = 0; +} + +void nl_dmx_configure(const nl_dmx_cfg_t *cfg) +{ + s_cfg = *cfg; + /* Sicherheitsnetz: start_slot=0 wuerde unten vor den Puffer lesen. Die REST-Schicht + * validiert 1..512, aber dieses Modul soll dafuer nicht auf einen Aufrufer angewiesen + * sein, den es nicht sehen kann. */ + if (s_cfg.start_slot < 1u) + s_cfg.start_slot = 1u; +} + +bool nl_dmx_active(void) { return s_override; } + +void nl_dmx_release(void) +{ + if (s_override) + nl_engine_override_clear(s_cfg.release_fade_ms); + s_override = false; + s_streaming = false; + s_prio = 0; +} + +/* Der Strom ist zu Ende - entweder durch das terminated-Bit oder durch Timeout. Im + * "release"-Modus geben wir den Override frei und blenden zurueck zum Zustand von + * nl_ctrl; im "hold"-Modus bleiben die zuletzt gesetzten Kanalwerte einfach stehen. */ +static void stream_ended(void) +{ + s_streaming = false; + s_prio = 0; + if (!s_cfg.hold && s_override) { + nl_engine_override_clear(s_cfg.release_fade_ms); + s_override = false; + } +} + +static uint8_t scale(uint8_t v, uint8_t dim) +{ + return (uint8_t)(((uint16_t)v * (uint16_t)dim + 127u) / 255u); +} + +void nl_dmx_feed(const nl_e131_packet_t *p, uint32_t now_ms) +{ + const uint8_t *s; + uint8_t ch[NL_CH_COUNT]; + uint8_t dim; + + if (s_board == NULL) + return; + /* Reicht das Paket bis zu unserem letzten Slot? Ein halb belegter Satz ergaebe auf den + * restlichen Kanaelen willkuerliche Werte - also gar nicht erst anwenden. */ + if ((uint32_t)s_cfg.start_slot + s_slots - 1u > (uint32_t)p->slot_count) + return; + if (s_streaming) { + if (p->priority < s_prio) + return; + if (p->priority == s_prio) { + /* E1.31-2018 Section 6.7.2: verwerfen, wenn die Differenz <= 0 und > -20 ist. + * Das Fenster ist bei -20 exklusiv - eine Sequenznummer genau 20 hinter der + * zuletzt angenommenen gilt bereits wieder als neu und wird angenommen. Nicht + * "korrigieren": ein Reviewer hat genau das schon einmal versucht. */ + int8_t d = (int8_t)(p->sequence - s_seq); + if (d <= 0 && d > -NL_DMX_SEQ_WINDOW) + return; /* veraltet oder Wiederholung */ + } + /* p->priority > s_prio: eine neue, hoeherpriore Quelle loest die laufende ab. Sie + * fuehrt ihre eigene Sequenzzaehlung - ein Abgleich gegen s_seq der abgeloesten + * Quelle waere Zufall und koennte die neue Quelle bis zu NL_DMX_SEQ_WINDOW Pakete + * lang verwerfen. Also: wie das erste Paket eines frischen Streams behandeln. */ + } + s_prio = p->priority; + s_seq = p->sequence; + s_last_ms = now_ms; + + if (p->terminated) { /* die Quelle sagt selbst, dass sie fertig ist */ + stream_ended(); + return; + } + s_streaming = true; + + s = p->slots + (s_cfg.start_slot - 1u); + dim = s[0]; + memset(ch, 0, sizeof ch); + if (s_board->has_rgb) { + ch[NL_CH_R] = scale(s[1], dim); + ch[NL_CH_G] = scale(s[2], dim); + ch[NL_CH_B] = scale(s[3], dim); + ch[NL_CH_CW] = scale(s[4], dim); + ch[NL_CH_WW] = scale(s[5], dim); + } else { + ch[NL_CH_CW] = scale(s[1], dim); + ch[NL_CH_WW] = scale(s[2], dim); + } + nl_engine_override(ch); + s_override = true; +} + +void nl_dmx_tick(uint32_t now_ms) +{ + if (!s_streaming) + return; + if (now_ms - s_last_ms >= NL_DMX_TIMEOUT_MS) + stream_ended(); +} diff --git a/src/light/nl_dmx.h b/src/light/nl_dmx.h new file mode 100644 index 0000000..af905c5 --- /dev/null +++ b/src/light/nl_dmx.h @@ -0,0 +1,32 @@ +#ifndef NL_DMX_H +#define NL_DMX_H + +#include +#include + +#include "board/nl_board.h" +#include "net/nl_e131.h" + +#define NL_DMX_TIMEOUT_MS 2500u /* E1.31, Abschnitt 6.7.1 */ +#define NL_DMX_SEQ_WINDOW 20 /* E1.31, Abschnitt 6.7.2 */ + +typedef struct { + uint16_t start_slot; /* 1..512 */ + bool hold; /* false = nach dem Timeout zurueck zum Zustand von nl_ctrl */ + uint16_t release_fade_ms; /* Dauer der Rueckblende (aus settings.fade_in_ms) */ +} nl_dmx_cfg_t; + +/* Die Engine muss initialisiert sein. Nicht threadsicher: Aufrufer serialisieren. */ +void nl_dmx_init(const nl_board_t *board); +void nl_dmx_configure(const nl_dmx_cfg_t *cfg); +/* Ein vom Parser angenommenes Paket. Prueft Sequenz, Prioritaet und Slotbereich und setzt + * bei Annahme den Override. */ +void nl_dmx_feed(const nl_e131_packet_t *p, uint32_t now_ms); +/* Aus dem Licht-Task (alle ~10 ms): prueft den Timeout. */ +void nl_dmx_tick(uint32_t now_ms); +/* Ein HA-/REST-Befehl holt die Kontrolle zurueck - in beiden Timeout-Modi. */ +void nl_dmx_release(void); +/* true, solange die Lampe ferngesteuert ist, einschliesslich der Haltephase. */ +bool nl_dmx_active(void); + +#endif diff --git a/src/light/nl_engine.c b/src/light/nl_engine.c index 039f89a..af1be8e 100644 --- a/src/light/nl_engine.c +++ b/src/light/nl_engine.c @@ -1,5 +1,7 @@ #include "light/nl_engine.h" +#include + #include "light/nl_color.h" typedef struct { @@ -19,6 +21,11 @@ static uint32_t s_blink_t; static bool s_pulse; static uint32_t s_pulse_t; +static bool s_ov; /* Override haelt die Ausgabe */ +static uint8_t s_ov_ch[NL_CH_COUNT]; /* zuletzt gesetzte Override-Kanaele */ +static uint32_t s_ov_fade_dur; /* > 0: Rueckblende laeuft */ +static uint32_t s_ov_fade_el; + static nl_visual_t to_visual(const nl_light_state_t *s) { nl_visual_t v; @@ -69,6 +76,18 @@ static void emit(void) v = s_cur; } nl_visual_to_channels(&v, s_cfg.ct_min, s_cfg.ct_max, ch); + + /* Blink und Pulse sind Rueckmeldungen des Geraets an den Menschen (Werksreset, + * Onboarding) - die muessen auch waehrend einer laufenden Show sichtbar bleiben. */ + if (!s_blink_left && !s_pulse) { + if (s_ov) { + memcpy(ch, s_ov_ch, sizeof ch); + } else if (s_ov_fade_dur) { + int i; + for (i = 0; i < NL_CH_COUNT; i++) + ch[i] = (uint8_t)lerp(s_ov_ch[i], ch[i], s_ov_fade_el, s_ov_fade_dur); + } + } s_cfg.output(ch, s_cfg.ctx); } @@ -84,6 +103,8 @@ void nl_engine_init(const nl_engine_cfg_t *cfg) s_blink_t = 0; s_pulse = false; s_pulse_t = 0; + s_ov = false; + s_ov_fade_dur = s_ov_fade_el = 0; emit(); } @@ -123,6 +144,12 @@ void nl_engine_tick(uint32_t elapsed_ms) { uint32_t left = elapsed_ms; + if (s_ov_fade_dur) { + s_ov_fade_el += elapsed_ms; + if (s_ov_fade_el >= s_ov_fade_dur) + s_ov_fade_dur = s_ov_fade_el = 0; + } + while (s_iseg < s_nseg) { seg_t *s = &s_seg[s_iseg]; if (s->elapsed + left >= s->dur) { @@ -180,3 +207,24 @@ void nl_engine_pulse(bool enable) s_pulse_t = 0; emit(); } + +void nl_engine_override(const uint8_t ch[NL_CH_COUNT]) +{ + memcpy(s_ov_ch, ch, sizeof s_ov_ch); + s_ov = true; + s_ov_fade_dur = s_ov_fade_el = 0; +} + +void nl_engine_override_clear(uint32_t fade_ms) +{ + if (!s_ov) + return; + s_ov = false; + s_ov_fade_dur = fade_ms; + s_ov_fade_el = 0; +} + +bool nl_engine_override_active(void) +{ + return s_ov; +} diff --git a/src/light/nl_engine.h b/src/light/nl_engine.h index 6a62cc4..3ee8079 100644 --- a/src/light/nl_engine.h +++ b/src/light/nl_engine.h @@ -29,4 +29,16 @@ void nl_engine_current(nl_visual_t *out); void nl_engine_blink(uint8_t count); void nl_engine_pulse(bool enable); +/* DMX-Override: die Kanalwerte gehen unveraendert an die Ausgabe, ohne Fade und ohne + * Farbmischung. Rangfolge in emit(): Blink > Pulse > Override > Fade-Engine. Der + * darunterliegende Zustand laeuft unberuehrt weiter. */ +void nl_engine_override(const uint8_t ch[NL_CH_COUNT]); +/* Beendet den Override. fade_ms > 0 blendet im Kanalraum auf das zurueck, was die + * Fade-Engine gerade ausgeben wuerde; 0 schaltet sofort um. */ +void nl_engine_override_clear(uint32_t fade_ms); +/* Nur fuer die Tests: sie haben sonst keinen Blick auf den Override-Zustand der Engine. + * Die Firmware fragt stattdessen nl_dmx_active() - wer hier nach einem Aufrufer im + * src/-Baum sucht, findet zu Recht keinen. */ +bool nl_engine_override_active(void); + #endif diff --git a/src/net/nl_api.c b/src/net/nl_api.c index a6ef00c..d649c5d 100644 --- a/src/net/nl_api.c +++ b/src/net/nl_api.c @@ -265,6 +265,17 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp) cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error); else cJSON_AddNullToObject(o, "last_ota_error"); + { + nl_settings_t s; + cJSON *sa = cJSON_AddObjectToObject(o, "sacn"); + ctx->get_settings(&s); + cJSON_AddBoolToObject(sa, "active", in.sacn_active); + cJSON_AddNumberToObject(sa, "universe", s.sacn_universe); + /* Sagt, ob der Beitritt zur Multicast-Gruppe gelungen ist. Ohne dieses Feld war bei + * der Abnahme nicht zu unterscheiden, ob die Lampe nicht beitritt oder das WLAN die + * Gruppe nicht weiterreicht - Unicast laeuft in beiden Faellen. */ + cJSON_AddBoolToObject(sa, "multicast", in.sacn_joined); + } reply_json(resp, 200, o); } @@ -286,6 +297,16 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp * kommen, ohne die Bedeutung von "ssid" zu aendern. */ cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled"); cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0); + { + cJSON *sa = cJSON_AddObjectToObject(o, "sacn"); + cJSON_AddBoolToObject(sa, "enabled", s.sacn_enabled != 0); + cJSON_AddNumberToObject(sa, "universe", s.sacn_universe); + cJSON_AddNumberToObject(sa, "start_slot", s.sacn_start_slot); + cJSON_AddStringToObject(sa, "on_timeout", s.sacn_hold ? "hold" : "release"); + /* Informativ und bewusst aus dem Board, nicht aus dem Datensatz: so steht beim + * Adressieren direkt da, wieviel Platz diese Lampe im Universe belegt. */ + cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board)); + } reply_json(resp, 200, o); } @@ -333,6 +354,34 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t err = "invalid all"; else s.auth_all = cJSON_IsTrue(all) ? 1 : 0; + } else if (strcmp(type, "sacn") == 0) { + const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled"); + const char *ot = NULL; + long un = 0, st = 0; + int unp, stp, otp; + uint8_t slots = nl_board_dmx_slots(ctx->board); + + if (en && !cJSON_IsBool(en)) { + err = "invalid enabled"; + } else if ((unp = get_int(j, "universe", 1, 63999, &un)) < 0) { + err = "invalid universe"; + /* Upper bound folds the board's slot count in: the highest start_slot that still + * leaves room for all of them is 512 - slots + 1. */ + } else if ((stp = get_int(j, "start_slot", 1, 512 - slots + 1, &st)) < 0) { + err = "invalid start_slot"; + } else if ((otp = get_string(j, "on_timeout", 1, 16, &ot)) < 0 || + (otp == 1 && strcmp(ot, "release") != 0 && strcmp(ot, "hold") != 0)) { + err = "invalid on_timeout"; + } else { + if (en) + s.sacn_enabled = cJSON_IsTrue(en) ? 1 : 0; + if (unp == 1) + s.sacn_universe = (uint16_t)un; + if (stp == 1) + s.sacn_start_slot = (uint16_t)st; + if (otp == 1) + s.sacn_hold = strcmp(ot, "hold") == 0 ? 1 : 0; + } } else { err = "invalid type"; } diff --git a/src/net/nl_api.h b/src/net/nl_api.h index 83980ee..afa2c01 100644 --- a/src/net/nl_api.h +++ b/src/net/nl_api.h @@ -24,6 +24,8 @@ typedef struct { uint32_t uptime_s; uint32_t free_heap; const char *last_ota_error; /* NULL until OTA exists */ + bool sacn_active; /* true, solange die Lampe ferngesteuert wird */ + bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */ } nl_sys_info_t; /* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */ diff --git a/src/net/nl_e131.c b/src/net/nl_e131.c new file mode 100644 index 0000000..bae8ed9 --- /dev/null +++ b/src/net/nl_e131.c @@ -0,0 +1,71 @@ +#include "net/nl_e131.h" + +#include + +static uint16_t be16(const uint8_t *p) { return (uint16_t)(((uint16_t)p[0] << 8) | p[1]); } + +static uint32_t be32(const uint8_t *p) +{ + return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) | ((uint32_t)p[2] << 8) | p[3]; +} + +/* Flags (obere 4 Bit) muessen 0x7 sein, die Laenge (untere 12 Bit) muss exakt den Rest des + * Datagramms ab `from` beschreiben. Ein Paket, dessen Laengenfelder nicht zueinander passen, + * ist entweder verstuemmelt oder ein Angriffsversuch - in beiden Faellen: weg damit. */ +static bool pdu_len_ok(const uint8_t *p, size_t len, size_t from) +{ + uint16_t fl = be16(p + from); + if ((fl & 0xf000u) != 0x7000u) + return false; + return (size_t)(fl & 0x0fffu) == len - from; +} + +/* Bewusste Abweichung von E1.31-2018 Abschnitt 6.2.4.1: die Synchronisationsadresse + * (Offset 109-110) und das Options-Bit "force synchronization" werden ueberlesen, das Paket + * wird sofort ausgegeben. Eine einzelne Lampe hat nichts zu synchronisieren, und auf ein + * Sync-Paket zu warten hiesse, bei einem Sender mit gesetzter Adresse dunkel zu bleiben. + * Das ist kein Versehen - fuer mehrere gemeinsam getaktete Empfaenger muesste es nachgeruestet + * werden. */ +bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out) +{ + static const uint8_t acn_pid[12] = { 'A', 'S', 'C', '-', 'E', '1', '.', '1', '7', 0, 0, 0 }; + uint16_t pvc; + + if (len < NL_E131_MIN_PACKET || len > NL_E131_MIN_PACKET + 512u) + return false; + if (be16(buf) != 0x0010u || be16(buf + 2) != 0x0000u) + return false; + if (memcmp(buf + 4, acn_pid, sizeof acn_pid) != 0) + return false; + if (!pdu_len_ok(buf, len, 16) || be32(buf + 18) != 0x00000004u) + return false; + if (!pdu_len_ok(buf, len, 38) || be32(buf + 40) != 0x00000002u) + return false; + if (!pdu_len_ok(buf, len, 115) || buf[117] != 0x02u) + return false; + if (buf[118] != 0xa1u || be16(buf + 119) != 0x0000u || be16(buf + 121) != 0x0001u) + return false; + pvc = be16(buf + 123); + if (pvc < 1u || (size_t)(pvc - 1u) != len - NL_E131_MIN_PACKET) + return false; + if (buf[125] != 0x00u) /* nur Start-Code 0 ist Lichtinhalt (0xdd waere Kanalprioritaet) */ + return false; + if (buf[112] & 0x80u) /* Preview-Daten duerfen laut E1.31 nicht ausgegeben werden */ + return false; + + /* Universe must be in range 1..63999; 0 and values >63999 are reserved. */ + uint16_t universe = be16(buf + 113); + if (universe == 0u || universe > 63999u) + return false; + + out->universe = universe; + out->priority = buf[108]; /* taken as-is without range check; priority is only compared + * relatively in later tasks, never against absolute bounds. + * strict validation would silently drop light data from slightly + * non-conformant senders, worse than accepting 0..255 */ + out->sequence = buf[111]; + out->terminated = (buf[112] & 0x40u) != 0u; + out->slot_count = (uint16_t)(pvc - 1u); + out->slots = buf + NL_E131_MIN_PACKET; + return true; +} diff --git a/src/net/nl_e131.h b/src/net/nl_e131.h new file mode 100644 index 0000000..9a403ce --- /dev/null +++ b/src/net/nl_e131.h @@ -0,0 +1,26 @@ +#ifndef NL_E131_H +#define NL_E131_H + +#include +#include +#include + +#define NL_E131_PORT 5568u +#define NL_E131_MAX_PACKET 640u /* ein volles Paket ist 638 Byte; 640 laesst Luft zum Erkennen + * ueberlanger Datagramme, ohne den Puffer unnoetig aufzublaehen */ +#define NL_E131_MIN_PACKET 126u + +typedef struct { + uint16_t universe; + uint8_t priority; + uint8_t sequence; + bool terminated; /* Options-Bit "stream terminated" */ + uint16_t slot_count; /* Nutzdaten ohne Start-Code, 0..512 */ + const uint8_t *slots; /* zeigt in den uebergebenen Puffer, wird nicht kopiert */ +} nl_e131_packet_t; + +/* true = gueltiges DMX-Datenpaket (Start-Code 0, kein Preview). Alles andere: false. + * `out` wird nur bei true beschrieben. */ +bool nl_e131_parse(const uint8_t *buf, size_t len, nl_e131_packet_t *out); + +#endif diff --git a/src/net/nl_sacn.c b/src/net/nl_sacn.c new file mode 100644 index 0000000..431bc69 --- /dev/null +++ b/src/net/nl_sacn.c @@ -0,0 +1,185 @@ +#include +#include + +#include "wm_include.h" +#include "wm_sockets.h" + +#include "light/nl_dmx.h" +#include "net/nl_e131.h" +#include "net/nl_sacn.h" +#include "net/nl_wifi.h" +#include "sys/nl_app.h" + +#define NL_SACN_PRIO 41 /* zwischen Licht (30) und HTTP (42) */ +#define NL_SACN_RECV_TIMEOUT_S 2 + +static u32 s_stack[512]; /* 2 KB: Parser ohne Heap, Puffer liegt statisch daneben */ +static uint8_t s_buf[NL_E131_MAX_PACKET]; + +/* Vom HTTP-Task geschrieben, vom Empfangs-Task gelesen. Jedes Feld passt in einen + * atomaren Zugriff; s_dirty wird zuletzt gesetzt und zuerst geloescht, damit der Task + * keine halb uebernommene Konfiguration sieht. */ +static volatile uint8_t s_enabled = 1; +static volatile uint16_t s_universe = 1; +static volatile bool s_dirty = true; + +volatile uint32_t nl_sacn_beat; + +/* Faellt nur aus, wenn der Task gar nicht erst entsteht. app_main fragt das ab: ohne Task + * bewegt sich nl_sacn_beat nie, und eine Wachhund-Bedingung, die trotzdem darauf besteht, + * wuerde die Lampe im 20-s-Takt endlos zuruecksetzen - ein Zusatzdienst darf sie nicht + * unbrauchbar machen. */ +static volatile bool s_running = true; +/* Ergebnis des letzten Multicast-Beitritts; ueber system/info sichtbar. */ +static volatile bool s_joined; + +void nl_sacn_apply_settings(const nl_settings_t *s) +{ + nl_dmx_cfg_t c; + + c.start_slot = s->sacn_start_slot; + c.hold = s->sacn_hold != 0; + c.release_fade_ms = s->fade_in_ms; + nl_app_lock(); + nl_dmx_configure(&c); + nl_app_unlock(); + + s_enabled = s->sacn_enabled; + s_universe = s->sacn_universe; + s_dirty = true; +} + +/* Legt den Socket an und tritt der Universe-Gruppe bei. -1 bei Fehlschlag. */ +static int open_socket(uint16_t universe, char ip_seen[16]) +{ + struct sockaddr_in addr; + struct ip_mreq mreq; + struct timeval tv; + nl_wifi_status_t w; + int on = 1; + int fd = socket(AF_INET, SOCK_DGRAM, 0); + + if (fd < 0) + return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof on); + memset(&addr, 0, sizeof addr); + addr.sin_family = AF_INET; + addr.sin_port = htons(NL_E131_PORT); + addr.sin_addr.s_addr = htonl(INADDR_ANY); + if (bind(fd, (struct sockaddr *)&addr, sizeof addr) != 0) { + closesocket(fd); + return -1; + } + /* Ohne Verkehr wuerde recvfrom() sonst ewig blockieren; dieser Timeout haelt den + * Herzschlag am Leben - derselbe Grund, aus dem der HTTP-Server accept() begrenzt. */ + tv.tv_sec = NL_SACN_RECV_TIMEOUT_S; + tv.tv_usec = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + + nl_wifi_status(&w); + strncpy(ip_seen, w.sta_ip, 15); + ip_seen[15] = '\0'; + + /* 239.255.. gemaess E1.31 Anhang A. Ein fehlgeschlagener Beitritt ist kein + * Abbruchgrund: Unicast an die Lampen-IP kommt auf demselben Socket weiterhin an. + * + * Das Ergebnis wird aber festgehalten und ueber system/info sichtbar gemacht. Bei der + * Abnahme am 2026-09-20 kam Multicast nicht an, waehrend Unicast lief - und weil der + * Rueckgabewert damals verworfen wurde, liess sich nicht unterscheiden, ob die Lampe + * nicht beitritt oder das WLAN die Gruppe nicht weiterreicht. Genau diese Frage + * beantwortet das Feld. */ + memset(&mreq, 0, sizeof mreq); + mreq.imr_multiaddr.s_addr = + htonl(0xefff0000u | ((uint32_t)(universe >> 8) << 8) | (universe & 0xffu)); + mreq.imr_interface.s_addr = ip_seen[0] ? inet_addr(ip_seen) : htonl(INADDR_ANY); + s_joined = setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof mreq) == 0; + printf("sacn: universe %u, multicast join %s\r\n", (unsigned)universe, + s_joined ? "ok" : "FEHLGESCHLAGEN"); + return fd; +} + +static void sacn_task(void *arg) +{ + int fd = -1; + char ip_seen[16] = ""; + uint16_t bound_universe = 0; + + (void)arg; + for (;;) { + nl_wifi_status_t w; + nl_e131_packet_t p; + uint32_t started_ms; + int n; + + nl_sacn_beat++; + + if (!s_enabled) { + if (fd >= 0) { + closesocket(fd); + fd = -1; + } + s_dirty = false; + tls_os_time_delay(HZ / 2); + continue; + } + + /* Neu aufbauen, wenn die Konfiguration sich geaendert hat oder die Station eine + * andere IP bekommen hat - nach einem WLAN-Wechsel gilt die alte Mitgliedschaft + * nicht mehr, und ohne Neubeitritt kaeme nie wieder ein Multicast-Paket an. */ + nl_wifi_status(&w); + if (fd >= 0 && (s_dirty || bound_universe != s_universe || + strncmp(ip_seen, w.sta_ip, sizeof ip_seen) != 0)) { + closesocket(fd); + fd = -1; + } + if (fd < 0) { + s_dirty = false; + bound_universe = s_universe; + fd = open_socket(bound_universe, ip_seen); + if (fd < 0) { + tls_os_time_delay(HZ); /* z.B. noch kein netif: spaeter erneut versuchen */ + continue; + } + } + + started_ms = nl_app_now_ms(); + n = recvfrom(fd, s_buf, sizeof s_buf, 0, NULL, NULL); + if (n < 0) { + /* Die Zeitueberschreitung ist der Normalfall ohne Verkehr und laeuft die vollen + * zwei Sekunden. Kommt der Fehler deutlich frueher, ist der Socket hart kaputt + * (das lwIP dieser Version reicht kein errno heraus, die Laufzeit ist das + * einzige Unterscheidungsmerkmal): neu aufbauen statt heiss zu drehen. */ + if (nl_app_now_ms() - started_ms < NL_SACN_RECV_TIMEOUT_S * 1000u / 2u) { + closesocket(fd); + fd = -1; + tls_os_time_delay(HZ); + } + continue; + } + if (!nl_e131_parse(s_buf, (size_t)n, &p)) + continue; + if (p.universe != bound_universe) + continue; + + /* nl_dmx teilt sich seinen Zustand mit der Licht-Engine und ist nicht threadsicher. */ + nl_app_lock(); + nl_dmx_feed(&p, nl_app_now_ms()); + nl_app_unlock(); + } +} + +void nl_sacn_start(void) +{ + if (tls_os_task_create(NULL, "nl_sacn", sacn_task, NULL, (u8 *)s_stack, sizeof(s_stack), + NL_SACN_PRIO, 0) != TLS_OS_SUCCESS) { + s_running = false; + printf("sacn: task create failed - receiver off\r\n"); + } +} + +bool nl_sacn_running(void) +{ + return s_running; +} + +bool nl_sacn_joined(void) { return s_joined; } diff --git a/src/net/nl_sacn.h b/src/net/nl_sacn.h new file mode 100644 index 0000000..3068ce9 --- /dev/null +++ b/src/net/nl_sacn.h @@ -0,0 +1,25 @@ +#ifndef NL_SACN_H +#define NL_SACN_H + +#include +#include + +#include "config/nl_settings.h" + +/* Wie nl_net_beat und nl_http_beat: app_main fuettert den Watchdog erst, wenn sich jeder + * Herzschlag bewegt hat - ein haengender Empfangs-Task muss den Chip zuruecksetzen. */ +extern volatile uint32_t nl_sacn_beat; + +/* Uebernimmt enabled/universe/start_slot/hold und fade_in_ms. Darf jederzeit aufgerufen + * werden; der Task baut seinen Socket beim naechsten Durchlauf neu auf. */ +void nl_sacn_apply_settings(const nl_settings_t *s); +void nl_sacn_start(void); + +/* false, wenn der Empfangs-Task nicht angelegt werden konnte. Dann bewegt sich + * nl_sacn_beat nie und der Wachhund darf ihn nicht mehr verlangen. */ +bool nl_sacn_running(void); +/* true, wenn der Beitritt zur Multicast-Gruppe des eingestellten Universe gelungen ist. + * Unicast an die Lampen-IP funktioniert unabhaengig davon. */ +bool nl_sacn_joined(void); + +#endif diff --git a/src/sys/nl_app_api.c b/src/sys/nl_app_api.c index 1fdc7f9..01a108b 100644 --- a/src/sys/nl_app_api.c +++ b/src/sys/nl_app_api.c @@ -5,9 +5,11 @@ #include "cJSON.h" #include "light/nl_control.h" +#include "light/nl_dmx.h" #include "light/nl_engine.h" #include "net/nl_mdns.h" #include "net/nl_names.h" +#include "net/nl_sacn.h" #include "net/nl_wifi.h" #include "sys/nl_app.h" #include "sys/nl_app_api.h" @@ -29,6 +31,9 @@ static nl_ctrl_err_t apply(const nl_light_change_t *c) { nl_ctrl_err_t e; nl_app_lock(); + /* Ein Befehl aus HA oder per curl holt die Kontrolle zurueck - auch im Modus "hold", + * in dem der Override sonst stehen bliebe. */ + nl_dmx_release(); e = nl_ctrl_apply(c, nl_app_now_ms()); nl_app_unlock(); return e; @@ -46,8 +51,10 @@ static void get_settings(nl_settings_t *out) static int save_settings(const nl_settings_t *s) { nl_settings_t *cur; + nl_settings_t applied; /* copy taken under the lock: nl_sacn_apply_settings() takes it itself */ char name[NL_NAME_LEN]; bool name_changed; + int r; nl_app_lock(); cur = nl_app_settings(); @@ -60,10 +67,14 @@ static int save_settings(const nl_settings_t *s) } nl_ctrl_set_options(cur->fade_in_ms, cur->fade_out_ms, cur->transition_ms, cur->wake_on_change != 0); memcpy(name, cur->name, sizeof name); + applied = *cur; nl_app_unlock(); if (name_changed) nl_mdns_set_name(name); - return nl_app_save_settings(); + r = nl_app_save_settings(); + if (r == 0) + nl_sacn_apply_settings(&applied); /* neues Universe/Startslot/hold sofort wirksam */ + return r; } static int wifi_configure(const char *ssid, const char *pass) { return nl_wifi_request_onboard(ssid, pass); } @@ -79,7 +90,13 @@ static void get_info(nl_sys_info_t *out) nl_names_mac_str(mac, out->mac); nl_app_lock(); memcpy(out->name, nl_app_settings()->name, sizeof out->name); + /* Gleicher Sperrabschnitt wie der Name: nl_dmx ist nicht threadsicher, und + * nl_app_lock() ist nicht rekursiv - also hier mit erledigen statt ein zweites Mal + * zu sperren. */ + out->sacn_active = nl_dmx_active(); nl_app_unlock(); + /* Ausserhalb der Sperre: nl_sacn haelt den Wert selbst, nl_dmx ist nicht beteiligt. */ + out->sacn_joined = nl_sacn_joined(); nl_wifi_status(&w); out->wifi_mode = w.mode; memcpy(out->ssid, w.ssid, sizeof out->ssid); diff --git a/src/sys/nl_console.c b/src/sys/nl_console.c index c3fc874..daf1e68 100644 --- a/src/sys/nl_console.c +++ b/src/sys/nl_console.c @@ -5,6 +5,7 @@ #include "wm_watchdog.h" #include "light/nl_control.h" +#include "light/nl_dmx.h" #include "light/nl_engine.h" #include "net/nl_wifi.h" #include "sys/nl_app.h" @@ -123,6 +124,10 @@ static void exec(const nl_cmd_t *c) } nl_app_lock(); + /* Wie in nl_app_api.c: ein Befehl von Hand holt die Kontrolle vom sACN-Sender zurueck. + * Die serielle Konsole ist der Rettungsweg - ohne das Freigeben bliebe eine im Modus + * "hold" dunkel geparkte Lampe dunkel und meldete trotzdem "ok". */ + nl_dmx_release(); err = nl_ctrl_apply(&ch, nl_app_now_ms()); nl_app_unlock(); if (err == NL_CTRL_OK) diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1c4a5c8..4e26696 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -44,6 +44,9 @@ nl_test(test_reset_counter test_reset_counter.c fake_flash.c ${NL_SRC}/config/nl nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c) nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c) nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c) +nl_test(test_e131 test_e131.c ${NL_SRC}/net/nl_e131.c) +nl_test(test_dmx test_dmx.c ${NL_SRC}/light/nl_dmx.c ${NL_SRC}/light/nl_engine.c + ${NL_SRC}/light/nl_color.c ${NL_SRC}/board/nl_board.c ${NL_SRC}/net/nl_e131.c) nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c ${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c) target_link_libraries(test_api PRIVATE nl_cjson) diff --git a/test/test_api.c b/test/test_api.c index ca25feb..dc39b67 100644 --- a/test/test_api.c +++ b/test/test_api.c @@ -346,6 +346,130 @@ static void test_settings_name_token_auth(void) TEST_ASSERT_EQUAL_INT(500, resp.status); } +/* --- system/settings: sacn --- */ +static const cJSON *sacn_field(const char *name) +{ + const cJSON *s = cJSON_GetObjectItemCaseSensitive(doc, "sacn"); + return cJSON_GetObjectItemCaseSensitive(s, name); +} + +static void test_settings_exposes_sacn_block(void) +{ + call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("enabled"))); + TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("universe"))); + TEST_ASSERT_EQUAL_INT(1, (int)cJSON_GetNumberValue(sacn_field("start_slot"))); + TEST_ASSERT_EQUAL_STRING("release", cJSON_GetStringValue(sacn_field("on_timeout"))); + TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(sacn_field("slots"))); +} + +static void test_settings_sacn_block_reflects_stored_values(void) +{ + fake_settings.sacn_enabled = 0; + fake_settings.sacn_universe = 12; + fake_settings.sacn_start_slot = 7; + fake_settings.sacn_hold = 1; + call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL); + TEST_ASSERT_FALSE(cJSON_IsTrue(sacn_field("enabled"))); + TEST_ASSERT_EQUAL_INT(12, (int)cJSON_GetNumberValue(sacn_field("universe"))); + TEST_ASSERT_EQUAL_INT(7, (int)cJSON_GetNumberValue(sacn_field("start_slot"))); + TEST_ASSERT_EQUAL_STRING("hold", cJSON_GetStringValue(sacn_field("on_timeout"))); +} + +static void test_put_sacn_all_fields(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"sacn\",\"enabled\":false,\"universe\":300," + "\"start_slot\":100,\"on_timeout\":\"hold\"}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_save_calls); + TEST_ASSERT_EQUAL_UINT8(0, fake_settings.sacn_enabled); + TEST_ASSERT_EQUAL_UINT16(300, fake_settings.sacn_universe); + TEST_ASSERT_EQUAL_UINT16(100, fake_settings.sacn_start_slot); + TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold); +} + +static void test_put_sacn_single_field_leaves_rest(void) +{ + fake_settings.sacn_universe = 9; + fake_settings.sacn_hold = 1; + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"sacn\",\"start_slot\":50}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_UINT16(50, fake_settings.sacn_start_slot); + TEST_ASSERT_EQUAL_UINT16(9, fake_settings.sacn_universe); + TEST_ASSERT_EQUAL_UINT8(1, fake_settings.sacn_hold); +} + +static void test_put_sacn_rejects_bad_values(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"enabled\":\"ja\"}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":0}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("invalid universe", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":64000}"); + TEST_ASSERT_EQUAL_STRING("invalid universe", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":0}"); + TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"on_timeout\":\"halten\"}"); + TEST_ASSERT_EQUAL_STRING("invalid on_timeout", error_text()); + + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); /* nichts davon darf gespeichert werden */ +} + +/* universe/start_slot muessen ueber get_int() laufen wie jedes andere Zahlenfeld in dieser + * Datei - ein Bruchwert darf nicht klammheimlich auf einen Integer abgeschnitten werden. */ +static void test_put_sacn_rejects_fractional_numbers(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"universe\":12.5}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("invalid universe", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":2.5}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text()); + + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); +} + +/* Ein Board mit RGB braucht 6 Slots: ab 508 passt der Satz nicht mehr ins Universe. */ +static void test_put_sacn_start_slot_must_leave_room_for_all_slots(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":507}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":508}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("invalid start_slot", error_text()); +} + +static void test_put_sacn_start_slot_limit_follows_the_board(void) +{ + fake_api_reset("g95_cw"); /* drei Slots: 510 passt noch */ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":510}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"sacn\",\"start_slot\":511}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); +} + +static void test_info_exposes_sacn_state(void) +{ + fake_info.sacn_active = true; + fake_info.sacn_joined = true; + fake_settings.sacn_universe = 5; + call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("active"))); + TEST_ASSERT_EQUAL_INT(5, (int)cJSON_GetNumberValue(sacn_field("universe"))); + TEST_ASSERT_TRUE(cJSON_IsTrue(sacn_field("multicast"))); +} + /* --- system/command --- */ static void test_commands(void) { @@ -406,6 +530,15 @@ int main(void) RUN_TEST(test_settings_wifi_source_reflects_compiled_fallback); RUN_TEST(test_settings_wifi); RUN_TEST(test_settings_name_token_auth); + RUN_TEST(test_settings_exposes_sacn_block); + RUN_TEST(test_settings_sacn_block_reflects_stored_values); + RUN_TEST(test_put_sacn_all_fields); + RUN_TEST(test_put_sacn_single_field_leaves_rest); + RUN_TEST(test_put_sacn_rejects_bad_values); + RUN_TEST(test_put_sacn_rejects_fractional_numbers); + RUN_TEST(test_put_sacn_start_slot_must_leave_room_for_all_slots); + RUN_TEST(test_put_sacn_start_slot_limit_follows_the_board); + RUN_TEST(test_info_exposes_sacn_state); RUN_TEST(test_commands); return UNITY_END(); } diff --git a/test/test_board.c b/test/test_board.c index 939db9b..45e8d89 100644 --- a/test/test_board.c +++ b/test/test_board.c @@ -106,6 +106,13 @@ static void test_known_values_from_old_firmware(void) TEST_ASSERT_EQUAL_INT8(3, g95->pwm[NL_CH_WW].pwm_ch); } +static void test_dmx_slots(void) +{ + TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("gu10_rgb"))); + TEST_ASSERT_EQUAL_UINT8(3, nl_board_dmx_slots(nl_board_find("g95_cw"))); + TEST_ASSERT_EQUAL_UINT8(6, nl_board_dmx_slots(nl_board_find("evk"))); +} + int main(void) { UNITY_BEGIN(); @@ -115,5 +122,6 @@ int main(void) RUN_TEST(test_pwm_boards_consistent); RUN_TEST(test_sm_boards_consistent); RUN_TEST(test_known_values_from_old_firmware); + RUN_TEST(test_dmx_slots); return UNITY_END(); } diff --git a/test/test_dmx.c b/test/test_dmx.c new file mode 100644 index 0000000..7d58375 --- /dev/null +++ b/test/test_dmx.c @@ -0,0 +1,326 @@ +#include +#include "unity.h" +#include "board/nl_board.h" +#include "light/nl_dmx.h" +#include "light/nl_engine.h" + +static uint8_t last_ch[NL_CH_COUNT]; + +static void sink(const uint8_t ch[NL_CH_COUNT], void *ctx) +{ + (void)ctx; + memcpy(last_ch, ch, sizeof last_ch); +} + +static uint8_t slot_data[16]; + +/* Baut ein Paket aus slot_data und speist es ein - als Funktion, damit kein Aufrufer + * die Adresse eines Rueckgabewerts nehmen muss. */ +static void feed(uint8_t prio, uint8_t seq, bool terminated, uint16_t count, uint32_t now_ms) +{ + nl_e131_packet_t p; + p.universe = 1; + p.priority = prio; + p.sequence = seq; + p.terminated = terminated; + p.slot_count = count; + p.slots = slot_data; + nl_dmx_feed(&p, now_ms); +} + +static void start(const char *board_id, uint16_t start_slot, bool hold) +{ + const nl_board_t *b = nl_board_find(board_id); + nl_engine_cfg_t cfg; + nl_dmx_cfg_t dc; + nl_light_state_t off = { false, 100, NL_MODE_WHITE, 0, 0, 0, 3000 }; + + cfg.ct_min = b->ct_min; + cfg.ct_max = b->ct_max; + cfg.output = sink; + cfg.ctx = NULL; + nl_engine_init(&cfg); + nl_engine_set(&off, 0); + nl_dmx_init(b); + dc.start_slot = start_slot; + dc.hold = hold; + dc.release_fade_ms = 0; + nl_dmx_configure(&dc); +} + +void setUp(void) +{ + memset(slot_data, 0, sizeof slot_data); + memset(last_ch, 0, sizeof last_ch); +} +void tearDown(void) {} + +static void test_first_packet_takes_over(void) +{ + /* Dimmer voll, R=10 G=20 B=30 CW=40 WW=50 */ + uint8_t d[6] = { 255, 10, 20, 30, 40, 50 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_TRUE(nl_dmx_active()); + TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]); + TEST_ASSERT_EQUAL_UINT8(20, last_ch[NL_CH_G]); + TEST_ASSERT_EQUAL_UINT8(30, last_ch[NL_CH_B]); + TEST_ASSERT_EQUAL_UINT8(40, last_ch[NL_CH_CW]); + TEST_ASSERT_EQUAL_UINT8(50, last_ch[NL_CH_WW]); +} + +static void test_master_dimmer_scales(void) +{ + uint8_t d[6] = { 128, 200, 200, 200, 200, 200 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_UINT8_WITHIN(1, 100, last_ch[NL_CH_R]); +} + +static void test_dimmer_zero_is_dark(void) +{ + uint8_t d[6] = { 0, 255, 255, 255, 255, 255 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_TRUE(nl_dmx_active()); /* dunkel, aber sehr wohl ferngesteuert */ + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]); + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_WW]); +} + +static void test_board_without_rgb_uses_three_slots(void) +{ + uint8_t d[3] = { 255, 77, 88 }; + start("g95_cw", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 3, 1000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(77, last_ch[NL_CH_CW]); + TEST_ASSERT_EQUAL_UINT8(88, last_ch[NL_CH_WW]); + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]); +} + +static void test_start_slot_offset(void) +{ + uint8_t d[9] = { 9, 9, 9, 255, 11, 22, 33, 44, 55 }; + start("gu10_rgb", 4, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 9, 1000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(11, last_ch[NL_CH_R]); + TEST_ASSERT_EQUAL_UINT8(55, last_ch[NL_CH_WW]); +} + +static void test_packet_too_short_for_our_slots_is_dropped(void) +{ + uint8_t d[4] = { 255, 1, 2, 3 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 4, 1000); /* braucht 6, bekommt 4 */ + nl_engine_tick(10); + TEST_ASSERT_FALSE(nl_dmx_active()); +} + +static void test_stale_sequence_is_dropped(void) +{ + uint8_t d[6] = { 255, 10, 0, 0, 0, 0 }; + uint8_t older[6] = { 255, 99, 0, 0, 0, 0 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 50, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]); + + memcpy(slot_data, older, sizeof older); + feed(100, 40, false, 6, 1010); /* 10 zurueck: im Fenster, verwerfen */ + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]); + + feed(100, 20, false, 6, 1020); /* 30 zurueck: ausserhalb, annehmen */ + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(99, last_ch[NL_CH_R]); +} + +static void test_a_packet_exactly_twenty_behind_is_accepted(void) +{ + /* E1.31-2018 6.7.2 verwirft "<= 0 und > -20" - bei d == -20 ist das Fenster bereits + * verlassen, das Paket gilt als neu. Ein Reviewer hat das schon einmal fuer einen + * Off-by-one gehalten; dieser Test legt fest, auf welcher Seite die Grenze liegt. */ + uint8_t d[6] = { 255, 10, 0, 0, 0, 0 }; + uint8_t twenty_behind[6] = { 255, 77, 0, 0, 0, 0 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 20, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]); + + memcpy(slot_data, twenty_behind, sizeof twenty_behind); + feed(100, 0, false, 6, 1010); /* d = 0 - 20 = -20: ausserhalb des Fensters, annehmen */ + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(77, last_ch[NL_CH_R]); +} + +static void test_higher_priority_source_takes_over_despite_stale_looking_sequence(void) +{ + /* Quelle A laeuft mit hoher Sequenznummer. Quelle B mit hoeherer Prioritaet startet mit + * einer Sequenz, die - gegen A's Zaehler geprueft - wie ein alter Wiederholer aussaehe. + * B fuehrt aber ihre eigene Zaehlung und muss trotzdem sofort uebernehmen. */ + uint8_t a[6] = { 255, 10, 0, 0, 0, 0 }; + uint8_t b[6] = { 255, 222, 0, 0, 0, 0 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, a, sizeof a); + feed(100, 50, false, 6, 1000); /* Quelle A, Prioritaet 100, Sequenz 50 */ + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]); + + memcpy(slot_data, b, sizeof b); + /* Quelle B, Prioritaet 150, Sequenz 45: gegen A's Zaehler (50) waere d = -5, also + * innerhalb des Verwerfungsfensters - trotzdem muss B sofort uebernehmen. */ + feed(150, 45, false, 6, 1010); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(222, last_ch[NL_CH_R]); +} + +static void test_lower_priority_is_ignored_while_streaming(void) +{ + uint8_t high[6] = { 255, 10, 0, 0, 0, 0 }; + uint8_t low[6] = { 255, 200, 0, 0, 0, 0 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, high, sizeof high); + feed(150, 1, false, 6, 1000); + nl_engine_tick(10); + + memcpy(slot_data, low, sizeof low); + feed(100, 2, false, 6, 1010); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(10, last_ch[NL_CH_R]); + + /* Nach dem Timeout ist wieder jede Prioritaet willkommen. */ + nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS + 1); + feed(100, 3, false, 6, 5000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(200, last_ch[NL_CH_R]); +} + +static void test_timeout_release_returns_to_engine(void) +{ + uint8_t d[6] = { 255, 255, 255, 255, 255, 255 }; + start("gu10_rgb", 1, false); /* die Lampe steht auf "aus" */ + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(255, last_ch[NL_CH_R]); + + nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS - 1); + TEST_ASSERT_TRUE(nl_dmx_active()); + nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS); + nl_engine_tick(10); + TEST_ASSERT_FALSE(nl_dmx_active()); + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]); /* wieder aus, wie von nl_ctrl gesetzt */ +} + +static void test_timeout_hold_keeps_last_values(void) +{ + uint8_t d[6] = { 255, 123, 0, 0, 0, 0 }; + start("gu10_rgb", 1, true); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + + nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS + 500); + nl_engine_tick(10); + TEST_ASSERT_TRUE(nl_dmx_active()); + TEST_ASSERT_EQUAL_UINT8(123, last_ch[NL_CH_R]); +} + +static void test_hold_keeps_darkness(void) +{ + uint8_t dark[6] = { 0, 0, 0, 0, 0, 0 }; + nl_light_state_t on = { true, 100, NL_MODE_WHITE, 0, 0, 0, 3000 }; + start("gu10_rgb", 1, true); + nl_engine_set(&on, 0); /* HA haette die Lampe gern an */ + memcpy(slot_data, dark, sizeof dark); + feed(100, 1, false, 6, 1000); + nl_dmx_tick(1000 + NL_DMX_TIMEOUT_MS + 1); + nl_engine_tick(10); + /* Genau der Fall, fuer den "hold" existiert: es bleibt dunkel. */ + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_WW]); +} + +static void test_terminated_bit_ends_immediately(void) +{ + uint8_t d[6] = { 255, 255, 0, 0, 0, 0 }; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + feed(100, 2, true, 6, 1010); + nl_engine_tick(10); + TEST_ASSERT_FALSE(nl_dmx_active()); + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]); +} + +static void test_release_hands_control_back_in_both_modes(void) +{ + uint8_t d[6] = { 255, 255, 0, 0, 0, 0 }; + int mode; + for (mode = 0; mode < 2; mode++) { + start("gu10_rgb", 1, mode == 1); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 6, 1000); + nl_engine_tick(10); + TEST_ASSERT_TRUE(nl_dmx_active()); + nl_dmx_release(); + nl_engine_tick(10); + TEST_ASSERT_FALSE(nl_dmx_active()); + TEST_ASSERT_EQUAL_UINT8(0, last_ch[NL_CH_R]); + } +} + +static void test_configure_during_stream_takes_effect(void) +{ + uint8_t d[9] = { 255, 1, 0, 0, 0, 0, 0, 0, 0 }; + nl_dmx_cfg_t dc; + start("gu10_rgb", 1, false); + memcpy(slot_data, d, sizeof d); + feed(100, 1, false, 9, 1000); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(1, last_ch[NL_CH_R]); + + slot_data[3] = 255; /* Slot 4 = Dimmer, wenn start_slot 4 ist */ + slot_data[4] = 222; /* Slot 5 = R, wenn start_slot 4 ist */ + dc.start_slot = 4; + dc.hold = false; + dc.release_fade_ms = 0; + nl_dmx_configure(&dc); + feed(100, 2, false, 9, 1010); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(222, last_ch[NL_CH_R]); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_first_packet_takes_over); + RUN_TEST(test_master_dimmer_scales); + RUN_TEST(test_dimmer_zero_is_dark); + RUN_TEST(test_board_without_rgb_uses_three_slots); + RUN_TEST(test_start_slot_offset); + RUN_TEST(test_packet_too_short_for_our_slots_is_dropped); + RUN_TEST(test_stale_sequence_is_dropped); + RUN_TEST(test_a_packet_exactly_twenty_behind_is_accepted); + RUN_TEST(test_higher_priority_source_takes_over_despite_stale_looking_sequence); + RUN_TEST(test_lower_priority_is_ignored_while_streaming); + RUN_TEST(test_timeout_release_returns_to_engine); + RUN_TEST(test_timeout_hold_keeps_last_values); + RUN_TEST(test_hold_keeps_darkness); + RUN_TEST(test_terminated_bit_ends_immediately); + RUN_TEST(test_release_hands_control_back_in_both_modes); + RUN_TEST(test_configure_during_stream_takes_effect); + return UNITY_END(); +} diff --git a/test/test_e131.c b/test/test_e131.c new file mode 100644 index 0000000..b03158f --- /dev/null +++ b/test/test_e131.c @@ -0,0 +1,217 @@ +#include +#include "unity.h" +#include "net/nl_e131.h" + +static uint8_t pkt[NL_E131_MAX_PACKET]; +static size_t pkt_len; + +static void be16(size_t off, uint16_t v) { pkt[off] = (uint8_t)(v >> 8); pkt[off + 1] = (uint8_t)v; } +static void be32(size_t off, uint32_t v) +{ + pkt[off] = (uint8_t)(v >> 24); pkt[off + 1] = (uint8_t)(v >> 16); + pkt[off + 2] = (uint8_t)(v >> 8); pkt[off + 3] = (uint8_t)v; +} + +/* Baut ein gueltiges Paket mit `slots` Slots, aufsteigend gefuellt (1, 2, 3, ...). */ +static void build(uint16_t universe, uint8_t prio, uint8_t seq, uint8_t options, uint16_t slots) +{ + pkt_len = 126u + slots; + memset(pkt, 0, sizeof pkt); + be16(0, 0x0010); + be16(2, 0x0000); + memcpy(pkt + 4, "ASC-E1.17\0\0\0", 12); + be16(16, (uint16_t)(0x7000u | (pkt_len - 16u))); + be32(18, 0x00000004u); + be16(38, (uint16_t)(0x7000u | (pkt_len - 38u))); + be32(40, 0x00000002u); + memcpy(pkt + 44, "ninlight test", 13); + pkt[108] = prio; + pkt[111] = seq; + pkt[112] = options; + be16(113, universe); + be16(115, (uint16_t)(0x7000u | (pkt_len - 115u))); + pkt[117] = 0x02; + pkt[118] = 0xa1; + be16(119, 0x0000); + be16(121, 0x0001); + be16(123, (uint16_t)(slots + 1u)); + pkt[125] = 0x00; /* Start-Code */ + for (uint16_t i = 0; i < slots; i++) + pkt[126 + i] = (uint8_t)(i + 1u); +} + +void setUp(void) { build(1, 100, 0, 0, 6); } +void tearDown(void) {} + +static void test_valid_packet(void) +{ + nl_e131_packet_t p; + TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p)); + TEST_ASSERT_EQUAL_UINT16(1, p.universe); + TEST_ASSERT_EQUAL_UINT8(100, p.priority); + TEST_ASSERT_EQUAL_UINT8(0, p.sequence); + TEST_ASSERT_FALSE(p.terminated); + TEST_ASSERT_EQUAL_UINT16(6, p.slot_count); + TEST_ASSERT_EQUAL_UINT8(1, p.slots[0]); + TEST_ASSERT_EQUAL_UINT8(6, p.slots[5]); +} + +static void test_full_universe(void) +{ + nl_e131_packet_t p; + build(1, 100, 0, 0, 512); + TEST_ASSERT_EQUAL_size_t(638, pkt_len); + TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p)); + TEST_ASSERT_EQUAL_UINT16(512, p.slot_count); +} + +static void test_empty_slot_set(void) +{ + nl_e131_packet_t p; + build(1, 100, 0, 0, 0); + TEST_ASSERT_EQUAL_size_t(126, pkt_len); + TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p)); + TEST_ASSERT_EQUAL_UINT16(0, p.slot_count); +} + +static void test_terminated_bit(void) +{ + nl_e131_packet_t p; + build(1, 100, 0, 0x40, 6); + TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p)); + TEST_ASSERT_TRUE(p.terminated); +} + +static void test_preview_bit_rejected(void) +{ + nl_e131_packet_t p; + build(1, 100, 0, 0x80, 6); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_bad_preamble(void) +{ + nl_e131_packet_t p; + pkt[1] = 0x11; + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_bad_package_identifier(void) +{ + nl_e131_packet_t p; + pkt[4] = 'X'; + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_bad_root_vector(void) +{ + nl_e131_packet_t p; + be32(18, 0x00000008u); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_bad_framing_vector(void) +{ + nl_e131_packet_t p; + be32(40, 0x00000001u); /* VECTOR_E131_EXTENDED_SYNCHRONIZATION */ + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_bad_dmp_vector(void) +{ + nl_e131_packet_t p; + pkt[117] = 0x03; + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_bad_address_type(void) +{ + nl_e131_packet_t p; + pkt[118] = 0xa2; + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_per_channel_priority_start_code_rejected(void) +{ + nl_e131_packet_t p; + pkt[125] = 0xdd; + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_truncated_length_fields(void) +{ + nl_e131_packet_t p; + /* Root-Laenge behauptet mehr, als das Datagramm hergibt. */ + be16(16, (uint16_t)(0x7000u | (pkt_len - 16u + 4u))); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); + build(1, 100, 0, 0, 6); + be16(38, (uint16_t)(0x7000u | (pkt_len - 38u - 2u))); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); + build(1, 100, 0, 0, 6); + be16(115, (uint16_t)(0x7000u | (pkt_len - 115u + 1u))); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_property_value_count_mismatch(void) +{ + nl_e131_packet_t p; + be16(123, 9); /* behauptet 8 Slots, das Paket traegt 6 */ + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_too_short_and_too_long(void) +{ + nl_e131_packet_t p; + TEST_ASSERT_FALSE(nl_e131_parse(pkt, 125, &p)); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, 0, &p)); + build(1, 100, 0, 0, 512); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, 639, &p)); /* laenger als jedes gueltige Paket */ +} + +static void test_universe_is_read(void) +{ + nl_e131_packet_t p; + build(63999, 200, 7, 0, 6); + TEST_ASSERT_TRUE(nl_e131_parse(pkt, pkt_len, &p)); + TEST_ASSERT_EQUAL_UINT16(63999, p.universe); + TEST_ASSERT_EQUAL_UINT8(200, p.priority); + TEST_ASSERT_EQUAL_UINT8(7, p.sequence); +} + +static void test_universe_zero_rejected(void) +{ + nl_e131_packet_t p; + build(0, 100, 0, 0, 6); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +static void test_universe_too_high_rejected(void) +{ + nl_e131_packet_t p; + build(64000, 100, 0, 0, 6); + TEST_ASSERT_FALSE(nl_e131_parse(pkt, pkt_len, &p)); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_valid_packet); + RUN_TEST(test_full_universe); + RUN_TEST(test_empty_slot_set); + RUN_TEST(test_terminated_bit); + RUN_TEST(test_preview_bit_rejected); + RUN_TEST(test_bad_preamble); + RUN_TEST(test_bad_package_identifier); + RUN_TEST(test_bad_root_vector); + RUN_TEST(test_bad_framing_vector); + RUN_TEST(test_bad_dmp_vector); + RUN_TEST(test_bad_address_type); + RUN_TEST(test_per_channel_priority_start_code_rejected); + RUN_TEST(test_truncated_length_fields); + RUN_TEST(test_property_value_count_mismatch); + RUN_TEST(test_too_short_and_too_long); + RUN_TEST(test_universe_is_read); + RUN_TEST(test_universe_zero_rejected); + RUN_TEST(test_universe_too_high_rejected); + return UNITY_END(); +} diff --git a/test/test_engine.c b/test/test_engine.c index 84963a0..bc74084 100644 --- a/test/test_engine.c +++ b/test/test_engine.c @@ -183,6 +183,86 @@ static void test_blink_max_clamped(void) TEST_ASSERT_TRUE(nl_engine_idle()); } +static void test_override_takes_over_output(void) +{ + nl_light_state_t on = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 3000); + uint8_t ov[NL_CH_COUNT] = { 10, 20, 30, 40, 50 }; + + nl_engine_set(&on, 0); + nl_engine_tick(10); + TEST_ASSERT_FALSE(nl_engine_override_active()); + + nl_engine_override(ov); + nl_engine_tick(10); + TEST_ASSERT_TRUE(nl_engine_override_active()); + TEST_ASSERT_EQUAL_UINT8(10, last[NL_CH_R]); + TEST_ASSERT_EQUAL_UINT8(50, last[NL_CH_WW]); +} + +static void test_override_clear_without_fade_is_immediate(void) +{ + nl_light_state_t off = st(false, 100, NL_MODE_WHITE, 0, 0, 0, 3000); + uint8_t ov[NL_CH_COUNT] = { 255, 255, 255, 255, 255 }; + + nl_engine_set(&off, 0); + nl_engine_tick(10); + nl_engine_override(ov); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(255, last[NL_CH_WW]); + + nl_engine_override_clear(0); + nl_engine_tick(10); + TEST_ASSERT_FALSE(nl_engine_override_active()); + TEST_ASSERT_EQUAL_UINT8(0, last[NL_CH_WW]); /* die Lampe war aus, also sofort dunkel */ +} + +static void test_override_clear_fades_back(void) +{ + nl_light_state_t off = st(false, 100, NL_MODE_WHITE, 0, 0, 0, 3000); + uint8_t ov[NL_CH_COUNT] = { 0, 0, 0, 0, 200 }; + + nl_engine_set(&off, 0); + nl_engine_tick(10); + nl_engine_override(ov); + nl_engine_tick(10); + + nl_engine_override_clear(1000); + nl_engine_tick(500); + /* auf halber Strecke zwischen 200 und 0, mit Spielraum fuer die Rundung */ + TEST_ASSERT_UINT8_WITHIN(12, 100, last[NL_CH_WW]); + nl_engine_tick(500); + TEST_ASSERT_EQUAL_UINT8(0, last[NL_CH_WW]); + TEST_ASSERT_FALSE(nl_engine_override_active()); +} + +static void test_blink_wins_over_override(void) +{ + uint8_t ov[NL_CH_COUNT] = { 0, 0, 0, 0, 0 }; + + nl_engine_override(ov); + nl_engine_blink(3); + nl_engine_tick(10); + /* Blink leuchtet hell weiss, obwohl der Override alles auf 0 haelt */ + TEST_ASSERT_NOT_EQUAL_UINT8(0, last[NL_CH_WW]); +} + +static void test_underlying_state_survives_the_override(void) +{ + nl_light_state_t on = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 3000); + uint8_t ov[NL_CH_COUNT] = { 0, 0, 0, 0, 0 }; + uint8_t before; + + nl_engine_set(&on, 0); + nl_engine_tick(10); + before = last[NL_CH_WW]; + + nl_engine_override(ov); + nl_engine_tick(2000); + nl_engine_override_clear(0); + nl_engine_tick(10); + TEST_ASSERT_EQUAL_UINT8(before, last[NL_CH_WW]); +} + int main(void) { UNITY_BEGIN(); @@ -197,5 +277,10 @@ int main(void) RUN_TEST(test_blink_overrides_and_restores); RUN_TEST(test_pulse); RUN_TEST(test_blink_max_clamped); + RUN_TEST(test_override_takes_over_output); + RUN_TEST(test_override_clear_without_fade_is_immediate); + RUN_TEST(test_override_clear_fades_back); + RUN_TEST(test_blink_wins_over_override); + RUN_TEST(test_underlying_state_survives_the_override); return UNITY_END(); } diff --git a/test/test_settings.c b/test/test_settings.c index 37d6cc6..790c0ff 100644 --- a/test/test_settings.c +++ b/test/test_settings.c @@ -182,6 +182,67 @@ static void test_erase(void) TEST_ASSERT_FALSE(nl_settings_load(&out)); } +static void test_sacn_defaults(void) +{ + nl_settings_t s; + TEST_ASSERT_FALSE(nl_settings_load(&s)); + TEST_ASSERT_EQUAL_UINT8(1, s.sacn_enabled); + TEST_ASSERT_EQUAL_UINT16(1, s.sacn_universe); + TEST_ASSERT_EQUAL_UINT16(1, s.sacn_start_slot); + TEST_ASSERT_EQUAL_UINT8(0, s.sacn_hold); +} + +static void test_sacn_roundtrip(void) +{ + nl_settings_t in = sample("dmx"), out; + in.sacn_enabled = 0; + in.sacn_universe = 4711; + in.sacn_start_slot = 300; + in.sacn_hold = 1; + TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in)); + TEST_ASSERT_TRUE(nl_settings_load(&out)); + TEST_ASSERT_EQUAL_UINT8(0, out.sacn_enabled); + TEST_ASSERT_EQUAL_UINT16(4711, out.sacn_universe); + TEST_ASSERT_EQUAL_UINT16(300, out.sacn_start_slot); + TEST_ASSERT_EQUAL_UINT8(1, out.sacn_hold); +} + +/* Ein Datensatz aus einer Firmware VOR sACN: die vier Felder muessen als echte Vorgaben + * zurueckkommen, nicht als Null - sonst waere sACN auf jeder aktualisierten Lampe aus. + * Die Laenge ist bewusst die Literalzahl 208: das war sizeof(nl_settings_t) vor diesem + * Zweig und damit die Laenge, die auf jeder ausgelieferten Lampe im Flash steht. Ein + * offsetof() auf ein heutiges Feld wuerde mit der Struktur mitwandern und genau den Fall + * verfehlen, um den es hier geht. Das letzte Byte (207) war im 208-Byte-Satz reines + * Fuellbyte und daher 0 - es wird hier nachgestellt, denn genau dieses Byte legt sich + * beim Lesen ueber das erste neue Feld. */ +static void test_record_without_sacn_gets_defaults(void) +{ + nl_settings_t in = sample("alt"), out; + uint32_t crc; + nl_settings_save(&in); + { + uint16_t shorter = 208u; /* sizeof(nl_settings_t) vor sACN */ + fake_flash_mem[NL_FL_SETTINGS_A + 12 + 207] = 0; /* Fuellbyte des alten Satzes */ + memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 6], &shorter, 2); + crc = nl_crc32(0, &fake_flash_mem[NL_FL_SETTINGS_A], 12 + shorter); + memcpy(&fake_flash_mem[NL_FL_SETTINGS_A + 12 + shorter], &crc, 4); + } + TEST_ASSERT_TRUE(nl_settings_load(&out)); + TEST_ASSERT_EQUAL_STRING("alt", out.name); + TEST_ASSERT_EQUAL_STRING("ninHome", out.wifi_ssid); + TEST_ASSERT_EQUAL_UINT8(1, out.sacn_enabled); + TEST_ASSERT_EQUAL_UINT16(1, out.sacn_universe); + TEST_ASSERT_EQUAL_UINT16(1, out.sacn_start_slot); + TEST_ASSERT_EQUAL_UINT8(0, out.sacn_hold); +} + +/* Die Satzlaenge muss durch 4 teilbar bleiben, sonst laufen Schreiber und Leser beim CRC + * auseinander (siehe NL_SETTINGS_CRC_OFF in nl_settings.c). */ +static void test_record_size_stays_aligned(void) +{ + TEST_ASSERT_EQUAL_UINT(0, sizeof(nl_settings_t) % 4u); +} + int main(void) { UNITY_BEGIN(); @@ -196,5 +257,9 @@ int main(void) RUN_TEST(test_ota_pending_default_and_roundtrip); RUN_TEST(test_crc_offset_matches_reader); RUN_TEST(test_erase); + RUN_TEST(test_sacn_defaults); + RUN_TEST(test_sacn_roundtrip); + RUN_TEST(test_record_without_sacn_gets_defaults); + RUN_TEST(test_record_size_stays_aligned); return UNITY_END(); } diff --git a/tools/sacn_send.py b/tools/sacn_send.py new file mode 100755 index 0000000..93c7acb --- /dev/null +++ b/tools/sacn_send.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Schickt sACN/E1.31-Pakete an eine ninlight-Lampe - fuer die Abnahme von Hand. + +Aufruf: + tools/sacn_send.py --universe 1 --start-slot 1 --values 255,255,0,0,0,0 --seconds 5 + tools/sacn_send.py --host 192.168.2.122 --values 255,0,255,0,0,0 --seconds 5 + tools/sacn_send.py --values 255,255,255,255,255,255 --seconds 3 --terminate + +Ohne --host geht es per Multicast an 239.255.., mit --host per Unicast direkt an +die Lampe. Die Werte sind der Slot-Satz ab --start-slot: Dimmer, R, G, B, CW, WW (Boards +ohne RGB: Dimmer, CW, WW). +""" +import argparse +import socket +import struct +import time + +E131_PORT = 5568 +ACN_PID = b"ASC-E1.17\0\0\0" + + +def packet(universe, sequence, values, start_slot, priority, terminated): + slots = bytearray(512) + for i, v in enumerate(values): + slots[start_slot - 1 + i] = v + count = start_slot - 1 + len(values) + data = bytes(slots[:count]) + total = 126 + count + + p = bytearray(total) + struct.pack_into(">HH", p, 0, 0x0010, 0x0000) + p[4:16] = ACN_PID + struct.pack_into(">H", p, 16, 0x7000 | (total - 16)) + struct.pack_into(">I", p, 18, 0x00000004) + p[22:38] = b"ninlight-abnahme" # CID, 16 Byte + struct.pack_into(">H", p, 38, 0x7000 | (total - 38)) + struct.pack_into(">I", p, 40, 0x00000002) + p[44:44 + 13] = b"ninlight test" + p[108] = priority + p[111] = sequence & 0xFF + p[112] = 0x40 if terminated else 0x00 + struct.pack_into(">H", p, 113, universe) + struct.pack_into(">H", p, 115, 0x7000 | (total - 115)) + p[117] = 0x02 + p[118] = 0xA1 + struct.pack_into(">HH", p, 119, 0x0000, 0x0001) + struct.pack_into(">H", p, 123, count + 1) + p[125] = 0x00 + p[126:] = data + return bytes(p) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--universe", type=int, default=1) + ap.add_argument("--start-slot", type=int, default=1) + ap.add_argument("--values", default="255,255,255,0,0,0") + ap.add_argument("--priority", type=int, default=100) + ap.add_argument("--seconds", type=float, default=5.0) + ap.add_argument("--fps", type=float, default=30.0) + ap.add_argument("--host", help="Unicast statt Multicast") + ap.add_argument("--terminate", action="store_true", + help="zum Schluss ein Paket mit gesetztem stream_terminated-Bit") + a = ap.parse_args() + + values = [int(v) for v in a.values.split(",")] + dest = a.host or "239.255.%d.%d" % (a.universe >> 8, a.universe & 0xFF) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 8) + + seq, deadline, step = 0, time.time() + a.seconds, 1.0 / a.fps + while time.time() < deadline: + s.sendto(packet(a.universe, seq, values, a.start_slot, a.priority, False), + (dest, E131_PORT)) + seq += 1 + time.sleep(step) + if a.terminate: + s.sendto(packet(a.universe, seq, values, a.start_slot, a.priority, True), + (dest, E131_PORT)) + print("%d Pakete an %s:%d, Universe %d" % (seq, dest, E131_PORT, a.universe)) + + +if __name__ == "__main__": + main()