merge: sACN/E1.31-Fernsteuerung

Die Lampe laesst sich per E1.31 ueber UDP fernsteuern, ohne dass der von Home
Assistant gesetzte Zustand angetastet wird. Abgenommen an einer E27 RGB, siehe
docs/acceptance-sacn.md - dort steht auch, dass Multicast derzeit am WLAN
haengenbleibt und nicht an der Firmware.

* feat/sacn:
  docs(sacn): Multicast-Ursache geklaert - die Lampe tritt bei, das WLAN filtert
  feat(sacn): Ergebnis des Multicast-Beitritts sichtbar machen
  docs(sacn): Abnahmeprotokoll der Hardwarepruefung
  docs(light,e131): zwei Stellen benennen, die sonst wie Versehen aussehen
  fix(sacn): ein nicht gestarteter Empfangs-Task darf die Lampe nicht unbrauchbar machen
  fix(console): Licht-Befehl gibt den DMX-Override frei - die Konsole ist der Rettungsweg
  fix(settings): neue Felder erst ab dem alten sizeof - sonst war sACN nach dem Update aus
  docs(sacn): Sender fuer die Abnahme, README und Bruno-Anfrage
  feat(sacn): UDP-Empfangs-Task und Verdrahtung in App und API
  fix(sacn): validate universe/start_slot via get_int(), reject fractional input
  feat(sacn): Endpunkt zum Einstellen und Statusfeld in system/info
  fix(sacn): sequence handling on priority change and start_slot clamp
  feat(sacn): Uebernahme-Schicht mit Timeout, Prioritaet und Sequenz
  feat(sacn): Override-Zweig in der Licht-Engine
  feat(sacn): Slotzahl je Board und vier Settings-Felder
  fix(sacn): validate universe range (1..63999), add boundary tests
  feat(sacn): E1.31-Pakete pruefen und zerlegen
This commit is contained in:
Michael Sauer
2026-09-20 02:33:49 +02:00
29 changed files with 1733 additions and 13 deletions
+3
View File
@@ -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)
+28 -1
View File
@@ -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: <token>`, 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: <token>`, Default-Token `aabbccddeeff0011223344`).
- Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'`
- Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`.
- Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen.
- Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler.
### sACN / E1.31
Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf
`239.255.<universe_hi>.<universe_lo>` und per Unicast direkt an ihre IP. Nach einem
Update ist der Empfang aktiv: Universe 1, Startslot 1.
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://<lampe>:3333/api/v1/system/settings \
-H 'Authorization: <token>' \
-d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}'
Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im
WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts
speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`.
Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`.
## Firmware-Update
Der Build erzeugt pro Gerät zwei Images:
+23 -1
View File
@@ -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
+81
View File
@@ -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.
+31 -7
View File
@@ -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();
}
}
+5
View File
@@ -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;
}
+4
View File
@@ -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
+25 -3
View File
@@ -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)
+8
View File
@@ -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);
+131
View File
@@ -0,0 +1,131 @@
#include "light/nl_dmx.h"
#include <string.h>
#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();
}
+32
View File
@@ -0,0 +1,32 @@
#ifndef NL_DMX_H
#define NL_DMX_H
#include <stdbool.h>
#include <stdint.h>
#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
+48
View File
@@ -1,5 +1,7 @@
#include "light/nl_engine.h"
#include <string.h>
#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;
}
+12
View File
@@ -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
+49
View File
@@ -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";
}
+2
View File
@@ -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. */
+71
View File
@@ -0,0 +1,71 @@
#include "net/nl_e131.h"
#include <string.h>
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;
}
+26
View File
@@ -0,0 +1,26 @@
#ifndef NL_E131_H
#define NL_E131_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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
+185
View File
@@ -0,0 +1,185 @@
#include <stdio.h>
#include <string.h>
#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.<hi>.<lo> 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; }
+25
View File
@@ -0,0 +1,25 @@
#ifndef NL_SACN_H
#define NL_SACN_H
#include <stdbool.h>
#include <stdint.h>
#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
+18 -1
View File
@@ -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);
+5
View File
@@ -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)
+3
View File
@@ -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)
+133
View File
@@ -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();
}
+8
View File
@@ -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();
}
+326
View File
@@ -0,0 +1,326 @@
#include <string.h>
#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();
}
+217
View File
@@ -0,0 +1,217 @@
#include <string.h>
#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();
}
+85
View File
@@ -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();
}
+65
View File
@@ -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();
}
+84
View File
@@ -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.<hi>.<lo>, 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()