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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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; }
|
||||
@@ -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
@@ -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,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)
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Executable
+84
@@ -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()
|
||||
Reference in New Issue
Block a user