From fae22f8c6422e441fa611a3da6e7b7cc1a226b1e Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 20 Sep 2026 00:08:49 +0200 Subject: [PATCH] feat(sacn): Uebernahme-Schicht mit Timeout, Prioritaet und Sequenz --- CMakeLists.txt | 1 + src/light/nl_dmx.c | 115 ++++++++++++++++++ src/light/nl_dmx.h | 32 +++++ test/CMakeLists.txt | 2 + test/test_dmx.c | 287 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 437 insertions(+) create mode 100644 src/light/nl_dmx.c create mode 100644 src/light/nl_dmx.h create mode 100644 test/test_dmx.c diff --git a/CMakeLists.txt b/CMakeLists.txt index df59c6f..b2831d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ set(NL_FW_SOURCES src/board/nl_board.c src/light/nl_color.c src/light/nl_engine.c + src/light/nl_dmx.c src/light/nl_control.c src/light/nl_boot_state.c src/config/nl_settings.c diff --git a/src/light/nl_dmx.c b/src/light/nl_dmx.c new file mode 100644 index 0000000..68c1bac --- /dev/null +++ b/src/light/nl_dmx.c @@ -0,0 +1,115 @@ +#include "light/nl_dmx.h" + +#include + +#include "light/nl_engine.h" + +static const nl_board_t *s_board; +static uint8_t s_slots; /* 3 oder 6 */ +static nl_dmx_cfg_t s_cfg; +static bool s_streaming; /* es fliessen Pakete */ +static bool s_override; /* die Engine haelt unsere Kanaele */ +static uint8_t s_prio; +static uint8_t s_seq; +static uint32_t s_last_ms; + +void nl_dmx_init(const nl_board_t *board) +{ + s_board = board; + s_slots = nl_board_dmx_slots(board); + s_cfg.start_slot = 1; + s_cfg.hold = false; + s_cfg.release_fade_ms = 0; + s_streaming = false; + s_override = false; + s_prio = 0; + s_seq = 0; + s_last_ms = 0; +} + +void nl_dmx_configure(const nl_dmx_cfg_t *cfg) { s_cfg = *cfg; } + +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; + { + int8_t d = (int8_t)(p->sequence - s_seq); + if (d <= 0 && d > -NL_DMX_SEQ_WINDOW) + return; /* veraltet oder Wiederholung */ + } + } + s_prio = p->priority; + s_seq = p->sequence; + s_last_ms = now_ms; + + if (p->terminated) { /* die Quelle sagt selbst, dass sie fertig ist */ + stream_ended(); + return; + } + s_streaming = true; + + s = p->slots + (s_cfg.start_slot - 1u); + dim = s[0]; + memset(ch, 0, sizeof ch); + if (s_board->has_rgb) { + ch[NL_CH_R] = scale(s[1], dim); + ch[NL_CH_G] = scale(s[2], dim); + ch[NL_CH_B] = scale(s[3], dim); + ch[NL_CH_CW] = scale(s[4], dim); + ch[NL_CH_WW] = scale(s[5], dim); + } else { + ch[NL_CH_CW] = scale(s[1], dim); + ch[NL_CH_WW] = scale(s[2], dim); + } + nl_engine_override(ch); + s_override = true; +} + +void nl_dmx_tick(uint32_t now_ms) +{ + if (!s_streaming) + return; + if (now_ms - s_last_ms >= NL_DMX_TIMEOUT_MS) + stream_ended(); +} diff --git a/src/light/nl_dmx.h b/src/light/nl_dmx.h new file mode 100644 index 0000000..af905c5 --- /dev/null +++ b/src/light/nl_dmx.h @@ -0,0 +1,32 @@ +#ifndef NL_DMX_H +#define NL_DMX_H + +#include +#include + +#include "board/nl_board.h" +#include "net/nl_e131.h" + +#define NL_DMX_TIMEOUT_MS 2500u /* E1.31, Abschnitt 6.7.1 */ +#define NL_DMX_SEQ_WINDOW 20 /* E1.31, Abschnitt 6.7.2 */ + +typedef struct { + uint16_t start_slot; /* 1..512 */ + bool hold; /* false = nach dem Timeout zurueck zum Zustand von nl_ctrl */ + uint16_t release_fade_ms; /* Dauer der Rueckblende (aus settings.fade_in_ms) */ +} nl_dmx_cfg_t; + +/* Die Engine muss initialisiert sein. Nicht threadsicher: Aufrufer serialisieren. */ +void nl_dmx_init(const nl_board_t *board); +void nl_dmx_configure(const nl_dmx_cfg_t *cfg); +/* Ein vom Parser angenommenes Paket. Prueft Sequenz, Prioritaet und Slotbereich und setzt + * bei Annahme den Override. */ +void nl_dmx_feed(const nl_e131_packet_t *p, uint32_t now_ms); +/* Aus dem Licht-Task (alle ~10 ms): prueft den Timeout. */ +void nl_dmx_tick(uint32_t now_ms); +/* Ein HA-/REST-Befehl holt die Kontrolle zurueck - in beiden Timeout-Modi. */ +void nl_dmx_release(void); +/* true, solange die Lampe ferngesteuert ist, einschliesslich der Haltephase. */ +bool nl_dmx_active(void); + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index d618089..4e26696 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -45,6 +45,8 @@ nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c) nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c) nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c) nl_test(test_e131 test_e131.c ${NL_SRC}/net/nl_e131.c) +nl_test(test_dmx test_dmx.c ${NL_SRC}/light/nl_dmx.c ${NL_SRC}/light/nl_engine.c + ${NL_SRC}/light/nl_color.c ${NL_SRC}/board/nl_board.c ${NL_SRC}/net/nl_e131.c) nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c ${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c) target_link_libraries(test_api PRIVATE nl_cjson) diff --git a/test/test_dmx.c b/test/test_dmx.c new file mode 100644 index 0000000..51aed52 --- /dev/null +++ b/test/test_dmx.c @@ -0,0 +1,287 @@ +#include +#include "unity.h" +#include "board/nl_board.h" +#include "light/nl_dmx.h" +#include "light/nl_engine.h" + +static uint8_t last_ch[NL_CH_COUNT]; +static int out_calls; + +static void sink(const uint8_t ch[NL_CH_COUNT], void *ctx) +{ + (void)ctx; + memcpy(last_ch, ch, sizeof last_ch); + out_calls++; +} + +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); + out_calls = 0; +} + +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_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_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(); +}