From 42eeb0e0ebd30bb5e86c4a77bec774520c4cbf25 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Fri, 11 Sep 2026 13:06:19 +0200 Subject: [PATCH] feat: Farbrechnung mit CIE-Helligkeit und CW/WW-Mischung --- CMakeLists.txt | 3 +- src/light/nl_color.c | 73 ++++++++++++++++++++++++ src/light/nl_color.h | 19 +++++++ src/light/nl_light_types.h | 29 ++++++++++ test/CMakeLists.txt | 1 + test/test_color.c | 112 +++++++++++++++++++++++++++++++++++++ 6 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 src/light/nl_color.c create mode 100644 src/light/nl_color.h create mode 100644 src/light/nl_light_types.h create mode 100644 test/test_color.c diff --git a/CMakeLists.txt b/CMakeLists.txt index b700633..09110c7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -30,7 +30,8 @@ set(NL_FW_SOURCES src/util/nl_crc.c src/sys/fwup_hook_default.c src/sys/sdk_stubs.c - src/board/nl_board.c) + src/board/nl_board.c + src/light/nl_color.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_color.c b/src/light/nl_color.c new file mode 100644 index 0000000..e559a3a --- /dev/null +++ b/src/light/nl_color.c @@ -0,0 +1,73 @@ +#include "light/nl_color.h" + +uint16_t nl_cie_level(uint16_t bri) +{ + float lightness, y; + uint32_t v; + + if (bri == 0) + return 0; + if (bri >= NL_BRI_SCALE) + return 65535; + lightness = (float)bri / 100.0f; /* 0..100 */ + if (lightness <= 8.0f) { + y = lightness / 903.3f; + } else { + float t = (lightness + 16.0f) / 116.0f; + y = t * t * t; + } + v = (uint32_t)(y * 65535.0f + 0.5f); + return (uint16_t)(v == 0 ? 1 : (v > 65535 ? 65535 : v)); +} + +uint16_t nl_ct_cw_fraction(uint16_t ct, uint16_t ct_min, uint16_t ct_max) +{ + uint32_t m, mw, mc; + + if (ct <= ct_min) + return 0; + if (ct >= ct_max) + return 65535; + m = 1000000u / ct; + mw = 1000000u / ct_min; + mc = 1000000u / ct_max; + return (uint16_t)(((mw - m) * 65535u) / (mw - mc)); +} + +static uint8_t to8(uint32_t lin) +{ + return (uint8_t)((lin * 255u + 32767u) / 65535u); +} + +void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_max, uint8_t out[NL_CH_COUNT]) +{ + uint32_t level = nl_cie_level(v->bri); + + for (int i = 0; i < NL_CH_COUNT; i++) + out[i] = 0; + if (level == 0) + return; + + if (v->mode == NL_MODE_COLOR) { + uint32_t lin[3]; + int maxi = 0; + lin[0] = level * v->r / 255u; + lin[1] = level * v->g / 255u; + lin[2] = level * v->b / 255u; + for (int i = 0; i < 3; i++) { + out[NL_CH_R + i] = to8(lin[i]); + if (lin[i] > lin[maxi]) + maxi = i; + } + /* keep the lowest brightness step visible */ + if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0) + out[NL_CH_R + maxi] = 1; + } else { + uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u; + uint32_t ww = level - cw; + out[NL_CH_CW] = to8(cw); + out[NL_CH_WW] = to8(ww); + if (!out[NL_CH_CW] && !out[NL_CH_WW]) + out[cw >= ww ? NL_CH_CW : NL_CH_WW] = 1; + } +} diff --git a/src/light/nl_color.h b/src/light/nl_color.h new file mode 100644 index 0000000..1d727b0 --- /dev/null +++ b/src/light/nl_color.h @@ -0,0 +1,19 @@ +#ifndef NL_COLOR_H +#define NL_COLOR_H + +#include + +#include "board/nl_board.h" +#include "light/nl_light_types.h" + +/* Perceived brightness (0..NL_BRI_SCALE) to linear light (0..65535), CIE 1931 lightness. + * Every bri > 0 yields at least 1. */ +uint16_t nl_cie_level(uint16_t bri); + +/* Share of cold white (0..65535) for ct, linear in mired between ct_min and ct_max. */ +uint16_t nl_ct_cw_fraction(uint16_t ct, uint16_t ct_min, uint16_t ct_max); + +/* 8 bit channel values for the LED drivers. In white mode cw + ww equals the brightness. */ +void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_max, uint8_t out[NL_CH_COUNT]); + +#endif diff --git a/src/light/nl_light_types.h b/src/light/nl_light_types.h new file mode 100644 index 0000000..18bf2e6 --- /dev/null +++ b/src/light/nl_light_types.h @@ -0,0 +1,29 @@ +#ifndef NL_LIGHT_TYPES_H +#define NL_LIGHT_TYPES_H + +#include +#include + +#define NL_BRI_SCALE 10000u /* visual brightness fixed point: 10000 = 100.00 % */ + +typedef enum { NL_MODE_COLOR = 0, NL_MODE_WHITE = 1 } nl_light_mode_t; +typedef enum { NL_POB_LAST = 0, NL_POB_ON = 1, NL_POB_OFF = 2 } nl_pob_t; + +/* Light state as seen by the API (brightness in percent). */ +typedef struct { + bool on; + uint8_t brightness; /* 1..100 */ + nl_light_mode_t mode; + uint8_t r, g, b; + uint16_t ct; /* Kelvin */ +} nl_light_state_t; + +/* What is currently visible; bri = 0 means dark. */ +typedef struct { + uint16_t bri; /* 0..NL_BRI_SCALE */ + nl_light_mode_t mode; + uint8_t r, g, b; + uint16_t ct; +} nl_visual_t; + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index adbcef2..4b174c5 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -25,3 +25,4 @@ endfunction() 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) diff --git a/test/test_color.c b/test/test_color.c new file mode 100644 index 0000000..6074f3a --- /dev/null +++ b/test/test_color.c @@ -0,0 +1,112 @@ +#include "unity.h" +#include "light/nl_color.h" + +void setUp(void) {} +void tearDown(void) {} + +static nl_visual_t vis(uint16_t bri, nl_light_mode_t mode, uint8_t r, uint8_t g, uint8_t b, uint16_t ct) +{ + nl_visual_t v = {bri, mode, r, g, b, ct}; + return v; +} + +static void test_cie_endpoints(void) +{ + TEST_ASSERT_EQUAL_UINT16(0, nl_cie_level(0)); + TEST_ASSERT_EQUAL_UINT16(65535, nl_cie_level(10000)); + TEST_ASSERT_EQUAL_UINT16(65535, nl_cie_level(12000)); + TEST_ASSERT_UINT16_WITHIN(1, 73, nl_cie_level(100)); /* 1 % -> L=1 -> Y=1/903.3 */ + TEST_ASSERT_UINT16_WITHIN(3, 12071, nl_cie_level(5000)); /* 50 % -> ((66/116)^3) */ +} + +static void test_cie_monotonic(void) +{ + uint16_t prev = 0; + for (uint16_t b = 1; b <= 10000; b++) { + uint16_t v = nl_cie_level(b); + TEST_ASSERT_TRUE(v >= prev); + TEST_ASSERT_TRUE(v > 0); + prev = v; + } +} + +static void test_ct_fraction(void) +{ + TEST_ASSERT_EQUAL_UINT16(0, nl_ct_cw_fraction(2700, 2700, 6500)); + TEST_ASSERT_EQUAL_UINT16(0, nl_ct_cw_fraction(2000, 2700, 6500)); + TEST_ASSERT_EQUAL_UINT16(65535, nl_ct_cw_fraction(6500, 2700, 6500)); + TEST_ASSERT_EQUAL_UINT16(65535, nl_ct_cw_fraction(9000, 2700, 6500)); + /* mired midpoint of 2700 K (370.4) and 6500 K (153.8) is 262.1 mired = 3815 K */ + TEST_ASSERT_UINT16_WITHIN(400, 32768, nl_ct_cw_fraction(3815, 2700, 6500)); +} + +static void test_white_endpoints(void) +{ + uint8_t ch[NL_CH_COUNT]; + nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 2700); + nl_visual_to_channels(&v, 2700, 6500, ch); + TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_WW]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]); + v.ct = 6500; + nl_visual_to_channels(&v, 2700, 6500, ch); + TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_CW]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]); +} + +static void test_white_mix_keeps_total(void) +{ + uint8_t ch[NL_CH_COUNT]; + nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 3815); + nl_visual_to_channels(&v, 2700, 6500, ch); + TEST_ASSERT_UINT8_WITHIN(3, 128, ch[NL_CH_CW]); + TEST_ASSERT_INT_WITHIN(1, 255, ch[NL_CH_CW] + ch[NL_CH_WW]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_R]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_G]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_B]); +} + +static void test_color_mode(void) +{ + uint8_t ch[NL_CH_COUNT]; + nl_visual_t v = vis(10000, NL_MODE_COLOR, 255, 120, 0, 2700); + nl_visual_to_channels(&v, 2700, 6500, ch); + TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_R]); + TEST_ASSERT_UINT8_WITHIN(1, 120, ch[NL_CH_G]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_B]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]); +} + +static void test_one_percent_is_visible(void) +{ + uint8_t ch[NL_CH_COUNT]; + nl_visual_t v = vis(100, NL_MODE_COLOR, 255, 0, 0, 2700); + nl_visual_to_channels(&v, 2700, 6500, ch); + TEST_ASSERT_EQUAL_UINT8(1, ch[NL_CH_R]); + v = vis(100, NL_MODE_WHITE, 0, 0, 0, 3000); + nl_visual_to_channels(&v, 2700, 6500, ch); + TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]); +} + +static void test_zero_brightness_is_dark(void) +{ + uint8_t ch[NL_CH_COUNT]; + nl_visual_t v = vis(0, NL_MODE_COLOR, 255, 255, 255, 2700); + nl_visual_to_channels(&v, 2700, 6500, ch); + for (int i = 0; i < NL_CH_COUNT; i++) + TEST_ASSERT_EQUAL_UINT8(0, ch[i]); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_cie_endpoints); + RUN_TEST(test_cie_monotonic); + RUN_TEST(test_ct_fraction); + RUN_TEST(test_white_endpoints); + RUN_TEST(test_white_mix_keeps_total); + RUN_TEST(test_color_mode); + RUN_TEST(test_one_percent_is_visible); + RUN_TEST(test_zero_brightness_is_dark); + return UNITY_END(); +}