diff --git a/CMakeLists.txt b/CMakeLists.txt index 09110c7..69711e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,7 +31,8 @@ set(NL_FW_SOURCES src/sys/fwup_hook_default.c src/sys/sdk_stubs.c src/board/nl_board.c - src/light/nl_color.c) + src/light/nl_color.c + src/light/nl_engine.c) set(NL_FW ninlight_${NINLIGHT_DEVICE}) add_executable(${NL_FW} ${NL_SDK_BOOT_SOURCES} ${NL_FW_SOURCES} ${NL_SDK_OBJECTS}) diff --git a/src/light/nl_engine.c b/src/light/nl_engine.c new file mode 100644 index 0000000..3de3f41 --- /dev/null +++ b/src/light/nl_engine.c @@ -0,0 +1,178 @@ +#include "light/nl_engine.h" + +#include "light/nl_color.h" + +typedef struct { + nl_visual_t from; + nl_visual_t to; + uint32_t dur; + uint32_t elapsed; +} seg_t; + +static nl_engine_cfg_t s_cfg; +static nl_visual_t s_cur; +static seg_t s_seg[2]; +static uint8_t s_nseg; +static uint8_t s_iseg; +static uint8_t s_blink_left; /* remaining blink phases; even = on, odd = off */ +static uint32_t s_blink_t; +static bool s_pulse; +static uint32_t s_pulse_t; + +static nl_visual_t to_visual(const nl_light_state_t *s) +{ + nl_visual_t v; + v.bri = s->on ? (uint16_t)(s->brightness * 100u) : 0; + v.mode = s->mode; + v.r = s->r; + v.g = s->g; + v.b = s->b; + v.ct = s->ct; + return v; +} + +static int32_t lerp(int32_t a, int32_t b, uint32_t e, uint32_t d) +{ + return a + (int32_t)(((int64_t)(b - a) * (int64_t)e) / (int64_t)d); +} + +static void push(const nl_visual_t *from, const nl_visual_t *to, uint32_t dur) +{ + seg_t *s = &s_seg[s_nseg++]; + s->from = *from; + s->to = *to; + s->dur = dur; + s->elapsed = 0; +} + +static void emit(void) +{ + uint8_t ch[NL_CH_COUNT]; + nl_visual_t v; + + if (s_blink_left) { + v.bri = (s_blink_left % 2 == 0) ? NL_BRI_SCALE : 0; + v.mode = NL_MODE_WHITE; + v.r = v.g = v.b = 0; + v.ct = s_cfg.ct_min; + } else if (s_pulse) { + uint32_t half = NL_PULSE_PERIOD_MS / 2; + uint32_t ph = s_pulse_t % NL_PULSE_PERIOD_MS; + uint32_t tri = ph < half ? ph : NL_PULSE_PERIOD_MS - ph; + v.bri = (uint16_t)(1000u + tri * 5000u / half); /* 10 % .. 60 % */ + v.mode = NL_MODE_WHITE; + v.r = v.g = v.b = 0; + v.ct = s_cfg.ct_min; + } else { + v = s_cur; + } + nl_visual_to_channels(&v, s_cfg.ct_min, s_cfg.ct_max, ch); + s_cfg.output(ch, s_cfg.ctx); +} + +void nl_engine_init(const nl_engine_cfg_t *cfg) +{ + s_cfg = *cfg; + s_cur.bri = 0; + s_cur.mode = NL_MODE_WHITE; + s_cur.r = s_cur.g = s_cur.b = 255; + s_cur.ct = cfg->ct_min; + s_nseg = s_iseg = 0; + s_blink_left = 0; + s_blink_t = 0; + s_pulse = false; + s_pulse_t = 0; + emit(); +} + +void nl_engine_set(const nl_light_state_t *target, uint32_t transition_ms) +{ + nl_visual_t tgt = to_visual(target); + + s_nseg = 0; + s_iseg = 0; + if (transition_ms == 0) { + s_cur = tgt; + emit(); + return; + } + if (s_cur.bri == 0) { + nl_visual_t start = tgt; + start.bri = 0; + push(&start, &tgt, transition_ms); + } else if (tgt.bri == 0) { + nl_visual_t dark = s_cur; + dark.bri = 0; + push(&s_cur, &dark, transition_ms); + push(&tgt, &tgt, 0); + } else if (tgt.mode != s_cur.mode) { + nl_visual_t a = s_cur; + nl_visual_t b = tgt; + a.bri = 0; + b.bri = 0; + push(&s_cur, &a, transition_ms / 2); + push(&b, &tgt, transition_ms - transition_ms / 2); + } else { + push(&s_cur, &tgt, transition_ms); + } +} + +void nl_engine_tick(uint32_t elapsed_ms) +{ + uint32_t left = elapsed_ms; + + while (s_iseg < s_nseg) { + seg_t *s = &s_seg[s_iseg]; + if (s->elapsed + left >= s->dur) { + left -= s->dur - s->elapsed; + s->elapsed = s->dur; + s_cur = s->to; + s_iseg++; + continue; + } + s->elapsed += left; + left = 0; + s_cur.bri = (uint16_t)lerp(s->from.bri, s->to.bri, s->elapsed, s->dur); + s_cur.r = (uint8_t)lerp(s->from.r, s->to.r, s->elapsed, s->dur); + s_cur.g = (uint8_t)lerp(s->from.g, s->to.g, s->elapsed, s->dur); + s_cur.b = (uint8_t)lerp(s->from.b, s->to.b, s->elapsed, s->dur); + s_cur.ct = (uint16_t)lerp(s->from.ct, s->to.ct, s->elapsed, s->dur); + s_cur.mode = s->to.mode; + break; + } + + if (s_blink_left) { + s_blink_t += elapsed_ms; + while (s_blink_left && s_blink_t >= NL_BLINK_PHASE_MS) { + s_blink_t -= NL_BLINK_PHASE_MS; + s_blink_left--; + } + } + if (s_pulse) + s_pulse_t += elapsed_ms; + emit(); +} + +bool nl_engine_idle(void) +{ + return s_iseg >= s_nseg && !s_blink_left && !s_pulse; +} + +void nl_engine_current(nl_visual_t *out) +{ + *out = s_cur; +} + +void nl_engine_blink(uint8_t count) +{ + s_blink_left = (uint8_t)(count * 2u); + s_blink_t = 0; + emit(); +} + +void nl_engine_pulse(bool enable) +{ + s_pulse = enable; + s_pulse_t = 0; + emit(); +} diff --git a/src/light/nl_engine.h b/src/light/nl_engine.h new file mode 100644 index 0000000..1c8f59c --- /dev/null +++ b/src/light/nl_engine.h @@ -0,0 +1,31 @@ +#ifndef NL_ENGINE_H +#define NL_ENGINE_H + +#include +#include + +#include "board/nl_board.h" +#include "light/nl_light_types.h" + +#define NL_BLINK_PHASE_MS 300u +#define NL_PULSE_PERIOD_MS 3000u + +typedef void (*nl_output_fn)(const uint8_t ch[NL_CH_COUNT], void *ctx); + +typedef struct { + uint16_t ct_min; + uint16_t ct_max; + nl_output_fn output; + void *ctx; +} nl_engine_cfg_t; + +/* Not thread safe: callers serialise access. */ +void nl_engine_init(const nl_engine_cfg_t *cfg); +void nl_engine_set(const nl_light_state_t *target, uint32_t transition_ms); +void nl_engine_tick(uint32_t elapsed_ms); +bool nl_engine_idle(void); +void nl_engine_current(nl_visual_t *out); +void nl_engine_blink(uint8_t count); +void nl_engine_pulse(bool enable); + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 4b174c5..10dbdc5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -26,3 +26,4 @@ nl_test(test_sha256 test_sha256.c ${NL_SRC}/util/nl_sha256.c) nl_test(test_crc test_crc.c ${NL_SRC}/util/nl_crc.c) nl_test(test_board test_board.c ${NL_SRC}/board/nl_board.c) nl_test(test_color test_color.c ${NL_SRC}/light/nl_color.c) +nl_test(test_engine test_engine.c ${NL_SRC}/light/nl_engine.c ${NL_SRC}/light/nl_color.c) diff --git a/test/test_engine.c b/test/test_engine.c new file mode 100644 index 0000000..e5af686 --- /dev/null +++ b/test/test_engine.c @@ -0,0 +1,188 @@ +#include +#include "unity.h" +#include "light/nl_engine.h" + +static uint8_t last[NL_CH_COUNT]; +static int calls; + +static void out(const uint8_t ch[NL_CH_COUNT], void *ctx) +{ + (void)ctx; + memcpy(last, ch, NL_CH_COUNT); + calls++; +} + +static nl_light_state_t st(bool on, uint8_t bri, nl_light_mode_t m, uint8_t r, uint8_t g, uint8_t b, uint16_t ct) +{ + nl_light_state_t s = {on, bri, m, r, g, b, ct}; + return s; +} + +static nl_visual_t cur(void) +{ + nl_visual_t v; + nl_engine_current(&v); + return v; +} + +static int sum(void) +{ + int s = 0; + for (int i = 0; i < NL_CH_COUNT; i++) + s += last[i]; + return s; +} + +void setUp(void) +{ + nl_engine_cfg_t cfg = {2700, 6500, out, NULL}; + memset(last, 0xAA, sizeof last); + calls = 0; + nl_engine_init(&cfg); +} + +void tearDown(void) {} + +static void test_init_is_dark_and_idle(void) +{ + TEST_ASSERT_TRUE(calls >= 1); + TEST_ASSERT_EQUAL_INT(0, sum()); + TEST_ASSERT_EQUAL_UINT16(0, cur().bri); + TEST_ASSERT_TRUE(nl_engine_idle()); +} + +static void test_instant_set(void) +{ + nl_light_state_t s = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 2700); + nl_engine_set(&s, 0); + TEST_ASSERT_EQUAL_UINT8(255, last[NL_CH_WW]); + TEST_ASSERT_EQUAL_UINT8(0, last[NL_CH_CW]); + TEST_ASSERT_TRUE(nl_engine_idle()); +} + +static void test_fade_in_is_linear_in_brightness(void) +{ + nl_light_state_t s = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 2700); + nl_engine_set(&s, 1000); + nl_engine_tick(500); + TEST_ASSERT_EQUAL_UINT16(5000, cur().bri); + TEST_ASSERT_FALSE(nl_engine_idle()); + nl_engine_tick(500); + TEST_ASSERT_EQUAL_UINT16(10000, cur().bri); + TEST_ASSERT_TRUE(nl_engine_idle()); + TEST_ASSERT_EQUAL_UINT8(255, last[NL_CH_WW]); +} + +static void test_fade_out_keeps_current_color(void) +{ + nl_light_state_t red = st(true, 100, NL_MODE_COLOR, 255, 0, 0, 2700); + nl_light_state_t off = st(false, 100, NL_MODE_WHITE, 0, 0, 0, 4000); + nl_engine_set(&red, 0); + nl_engine_set(&off, 1000); + nl_engine_tick(500); + TEST_ASSERT_EQUAL(NL_MODE_COLOR, cur().mode); + TEST_ASSERT_EQUAL_UINT16(5000, cur().bri); + TEST_ASSERT_TRUE(last[NL_CH_R] > 0); + nl_engine_tick(500); + TEST_ASSERT_EQUAL_INT(0, sum()); + TEST_ASSERT_TRUE(nl_engine_idle()); + TEST_ASSERT_EQUAL(NL_MODE_WHITE, cur().mode); /* target values applied at the end */ +} + +static void test_mode_switch_passes_through_dark(void) +{ + nl_light_state_t red = st(true, 100, NL_MODE_COLOR, 255, 0, 0, 2700); + nl_light_state_t white = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 4000); + nl_engine_set(&red, 0); + nl_engine_set(&white, 1000); + nl_engine_tick(250); + TEST_ASSERT_EQUAL(NL_MODE_COLOR, cur().mode); + TEST_ASSERT_EQUAL_UINT16(5000, cur().bri); + nl_engine_tick(250); + TEST_ASSERT_EQUAL_UINT16(0, cur().bri); + TEST_ASSERT_EQUAL_INT(0, sum()); + nl_engine_tick(250); + TEST_ASSERT_EQUAL(NL_MODE_WHITE, cur().mode); + TEST_ASSERT_EQUAL_UINT16(5000, cur().bri); + nl_engine_tick(250); + TEST_ASSERT_EQUAL_UINT16(10000, cur().bri); + TEST_ASSERT_TRUE(nl_engine_idle()); +} + +static void test_leftover_time_carries_into_next_segment(void) +{ + nl_light_state_t red = st(true, 100, NL_MODE_COLOR, 255, 0, 0, 2700); + nl_light_state_t white = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 4000); + nl_engine_set(&red, 0); + nl_engine_set(&white, 1000); + nl_engine_tick(750); + TEST_ASSERT_EQUAL(NL_MODE_WHITE, cur().mode); + TEST_ASSERT_EQUAL_UINT16(5000, cur().bri); +} + +static void test_new_target_starts_from_current_value(void) +{ + nl_light_state_t full = st(true, 100, NL_MODE_WHITE, 0, 0, 0, 2700); + nl_light_state_t dim = st(true, 20, NL_MODE_WHITE, 0, 0, 0, 2700); + nl_engine_set(&full, 1000); + nl_engine_tick(500); + nl_engine_set(&dim, 1000); + nl_engine_tick(500); + TEST_ASSERT_EQUAL_UINT16(3500, cur().bri); +} + +static void test_color_interpolates(void) +{ + nl_light_state_t red = st(true, 100, NL_MODE_COLOR, 255, 0, 0, 2700); + nl_light_state_t blue = st(true, 100, NL_MODE_COLOR, 0, 0, 255, 2700); + nl_engine_set(&red, 0); + nl_engine_set(&blue, 1000); + nl_engine_tick(500); + TEST_ASSERT_INT_WITHIN(1, 128, cur().r); + TEST_ASSERT_INT_WITHIN(1, 127, cur().b); +} + +static void test_blink_overrides_and_restores(void) +{ + nl_engine_blink(2); + TEST_ASSERT_EQUAL_UINT8(255, last[NL_CH_WW]); + TEST_ASSERT_FALSE(nl_engine_idle()); + nl_engine_tick(300); + TEST_ASSERT_EQUAL_INT(0, sum()); + nl_engine_tick(300); + TEST_ASSERT_EQUAL_UINT8(255, last[NL_CH_WW]); + nl_engine_tick(300); + TEST_ASSERT_EQUAL_INT(0, sum()); + nl_engine_tick(300); + TEST_ASSERT_EQUAL_INT(0, sum()); /* back to the (dark) state */ + TEST_ASSERT_TRUE(nl_engine_idle()); +} + +static void test_pulse(void) +{ + nl_engine_pulse(true); + uint8_t low = last[NL_CH_WW]; + TEST_ASSERT_TRUE(low > 0); + nl_engine_tick(1500); + TEST_ASSERT_TRUE(last[NL_CH_WW] > low); + TEST_ASSERT_FALSE(nl_engine_idle()); + nl_engine_pulse(false); + TEST_ASSERT_EQUAL_INT(0, sum()); + TEST_ASSERT_TRUE(nl_engine_idle()); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_init_is_dark_and_idle); + RUN_TEST(test_instant_set); + RUN_TEST(test_fade_in_is_linear_in_brightness); + RUN_TEST(test_fade_out_keeps_current_color); + RUN_TEST(test_mode_switch_passes_through_dark); + RUN_TEST(test_leftover_time_carries_into_next_segment); + RUN_TEST(test_new_target_starts_from_current_value); + RUN_TEST(test_color_interpolates); + RUN_TEST(test_blink_overrides_and_restores); + RUN_TEST(test_pulse); + return UNITY_END(); +}