diff --git a/bruno/ninlight.yml b/bruno/ninlight.yml index 2d95d8e..16e7e79 100644 --- a/bruno/ninlight.yml +++ b/bruno/ninlight.yml @@ -96,7 +96,7 @@ items: followRedirects: true maxRedirects: 5 - info: - name: Color Red + name: Color Red + Transition type: http seq: 5 http: @@ -110,7 +110,8 @@ items: "r": 255, "g": 0, "b": 0 - } + }, + "transition": 1500 } settings: encodeUrl: true @@ -118,7 +119,7 @@ items: followRedirects: true maxRedirects: 5 - info: - name: Color Temp 2700 + name: Color Temp 2700 + Transition type: http seq: 6 http: @@ -128,7 +129,8 @@ items: type: json data: |- { - "color_temp": 2700 + "color_temp": 2700, + "transition": 1500 } settings: encodeUrl: true @@ -192,6 +194,25 @@ items: timeout: 0 followRedirects: true maxRedirects: 5 + - info: + name: Default Transition + Wake Switch + type: http + seq: 10 + http: + method: POST + url: '{{device_address}}:{{device_port}}/api/v1/device/state' + body: + type: json + data: |- + { + "transition_default": 300, + "wake_on_change": false + } + settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 - info: name: System type: folder diff --git a/src/app_main.c b/src/app_main.c index d3125bd..7b2f96f 100644 --- a/src/app_main.c +++ b/src/app_main.c @@ -202,7 +202,8 @@ static void app_task(void *arg) after_ota); nl_app_lock(); nl_engine_init(&cfg); - nl_ctrl_init(board, s_settings.fade_in_ms, s_settings.fade_out_ms); + 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_app_unlock(); diff --git a/src/config/nl_settings.c b/src/config/nl_settings.c index 5376a16..ec97167 100644 --- a/src/config/nl_settings.c +++ b/src/config/nl_settings.c @@ -47,6 +47,8 @@ void nl_settings_defaults(nl_settings_t *s) s->power_on_behaviour = NL_POB_LAST; s->fade_in_ms = 500; s->fade_out_ms = 500; + s->transition_ms = 300; + s->wake_on_change = 0; } void nl_settings_init(uint32_t addr_a, uint32_t addr_b) diff --git a/src/config/nl_settings.h b/src/config/nl_settings.h index 5b5f5ba..1941be8 100644 --- a/src/config/nl_settings.h +++ b/src/config/nl_settings.h @@ -19,6 +19,15 @@ typedef struct { uint16_t fade_in_ms; uint16_t fade_out_ms; uint8_t ota_pending; /* set before an OTA; the next boot restores the last state */ + /* Appended fields: read_rec() in nl_settings.c pre-fills the struct with + * nl_settings_defaults() and only overwrites the bytes actually stored, so records + * written by an older firmware pick up the real default here, not zero. Keep adding new + * fields at the end for that to keep working. */ + uint16_t transition_ms; /* default transition (ms) for a color/brightness/CT change + * while the lamp stays on; fade_in/fade_out still win when + * 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. */ } nl_settings_t; void nl_settings_defaults(nl_settings_t *s); diff --git a/src/drivers/nl_sm2135eh.c b/src/drivers/nl_sm2135eh.c index 6730251..5a14e07 100644 --- a/src/drivers/nl_sm2135eh.c +++ b/src/drivers/nl_sm2135eh.c @@ -88,9 +88,15 @@ void nl_sm2135eh_set(const uint8_t ch[NL_CH_COUNT]) uint8_t d[6] = {SM_ADDR_CURRENT, s_current, chan, ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]}; sm_send(d, 6); } else { - uint8_t h[3] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY}; + /* The RGB data registers are written along with the channel select, even though they + are always zero here: the chip keeps whatever colour was last written, and a direct + colour -> white change never passes through the all-dark frame above. The stale + colour then bleeds through as soon as the white dims - visible as a coloured + fade-out on a migrated GU10 RGB, 2026-09-13. */ + uint8_t h[6] = {SM_ADDR_CURRENT, s_current, SM_CHANNEL_WY, + ch[NL_CH_B], ch[NL_CH_G], ch[NL_CH_R]}; uint8_t d[3] = {SM_ADDR_WY, ch[NL_CH_WW], ch[NL_CH_CW]}; - sm_send(h, 3); + sm_send(h, 6); sm_send(d, 3); } } diff --git a/src/light/nl_control.c b/src/light/nl_control.c index 0ed78df..e88dc08 100644 --- a/src/light/nl_control.c +++ b/src/light/nl_control.c @@ -7,23 +7,29 @@ static const nl_board_t *s_board; static nl_light_state_t s_state; static uint16_t s_fade_in; static uint16_t s_fade_out; +static uint16_t s_transition; +static bool s_wake_on_change; static bool s_dirty; static uint32_t s_changed_at; -void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms) +void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms, + uint16_t transition_ms, bool wake_on_change) { s_board = board; s_state = nl_boot_default_state(board); s_state.on = false; s_dirty = false; s_changed_at = 0; - nl_ctrl_set_fades(fade_in_ms, fade_out_ms); + nl_ctrl_set_options(fade_in_ms, fade_out_ms, transition_ms, wake_on_change); } -void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms) +void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms, + bool wake_on_change) { s_fade_in = fade_in_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_in_ms; s_fade_out = fade_out_ms > NL_MAX_FADE_MS ? NL_MAX_FADE_MS : fade_out_ms; + s_transition = transition_ms > NL_MAX_TRANSITION_MS ? NL_MAX_TRANSITION_MS : transition_ms; + s_wake_on_change = wake_on_change; } void nl_ctrl_boot(const nl_light_state_t *initial) @@ -62,6 +68,13 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms) n.mode = NL_MODE_WHITE; } + /* Weckschalter: a color/CT/brightness change arriving while the lamp is off switches it + * on, unless this same call also says "on": false explicitly - that always wins. Applied + * before the duration below so an off->on transition here uses fade_in like any other. */ + if (s_wake_on_change && !s_state.on && !n.on && !(c->has_on && !c->on) && + (c->has_color || c->has_ct || c->has_brightness)) + n.on = true; + if (c->has_transition) dur = c->transition_ms; else if (!s_state.on && n.on) @@ -69,7 +82,7 @@ nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms) else if (s_state.on && !n.on) dur = s_fade_out; else - dur = 0; + dur = s_transition; s_state = n; nl_engine_set(&s_state, dur); diff --git a/src/light/nl_control.h b/src/light/nl_control.h index 7dd2432..50c202d 100644 --- a/src/light/nl_control.h +++ b/src/light/nl_control.h @@ -32,9 +32,17 @@ typedef enum { NL_CTRL_ERR_TRANSITION } nl_ctrl_err_t; -/* The engine must be initialised before. Not thread safe: callers serialise access. */ -void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms); -void nl_ctrl_set_fades(uint16_t fade_in_ms, uint16_t fade_out_ms); +/* The engine must be initialised before. Not thread safe: callers serialise access. + * + * transition_ms: default transition applied to a color/brightness/CT change while the lamp + * stays on and the caller did not send its own "transition" (fade_in/fade_out still take + * precedence when the lamp is switching on or off). + * wake_on_change: if true, a color/CT/brightness change arriving while the lamp is off + * switches it on (using fade_in), unless the same call also says "on": false. */ +void nl_ctrl_init(const nl_board_t *board, uint16_t fade_in_ms, uint16_t fade_out_ms, + uint16_t transition_ms, bool wake_on_change); +void nl_ctrl_set_options(uint16_t fade_in_ms, uint16_t fade_out_ms, uint16_t transition_ms, + bool wake_on_change); void nl_ctrl_boot(const nl_light_state_t *initial); nl_ctrl_err_t nl_ctrl_apply(const nl_light_change_t *c, uint32_t now_ms); void nl_ctrl_get(nl_light_state_t *out); diff --git a/src/net/nl_api.c b/src/net/nl_api.c index 888f8f6..a6ef00c 100644 --- a/src/net/nl_api.c +++ b/src/net/nl_api.c @@ -115,6 +115,11 @@ static cJSON *state_json(const nl_api_ctx_t *ctx) POB_NAMES[s.power_on_behaviour <= NL_POB_OFF ? s.power_on_behaviour : NL_POB_LAST]); cJSON_AddNumberToObject(o, "fade_in", s.fade_in_ms); cJSON_AddNumberToObject(o, "fade_out", s.fade_out_ms); + /* Named "transition_default", not "transition": the latter is already the per-call field + * above the settings section of this same request/response and a second field with the + * same key in one JSON object would collide with it. */ + cJSON_AddNumberToObject(o, "transition_default", s.transition_ms); + cJSON_AddBoolToObject(o, "wake_on_change", s.wake_on_change != 0); cap = cJSON_AddObjectToObject(o, "capabilities"); cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb); ct[0] = ctx->board->ct_min; @@ -193,6 +198,17 @@ static void handle_post_state(const nl_api_ctx_t *ctx, const nl_http_request_t * case 1: s.fade_out_ms = (uint16_t)v; settings = true; break; default: break; } + switch (get_int(j, "transition_default", 0, NL_MAX_TRANSITION_MS, &v)) { + case -1: err = "invalid transition_default"; goto out; + case 1: s.transition_ms = (uint16_t)v; settings = true; break; + default: break; + } + it = cJSON_GetObjectItemCaseSensitive(j, "wake_on_change"); + if (it) { + if (!cJSON_IsBool(it)) { err = "invalid wake_on_change"; goto out; } + s.wake_on_change = cJSON_IsTrue(it) ? 1u : 0u; + settings = true; + } if (light) { switch (ctx->apply(&ch)) { diff --git a/src/sys/nl_app_api.c b/src/sys/nl_app_api.c index f6d5fc4..1fdc7f9 100644 --- a/src/sys/nl_app_api.c +++ b/src/sys/nl_app_api.c @@ -58,7 +58,7 @@ static int save_settings(const nl_settings_t *s) memcpy(merged.wifi_pass, cur->wifi_pass, sizeof merged.wifi_pass); *cur = merged; } - nl_ctrl_set_fades(cur->fade_in_ms, cur->fade_out_ms); + 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); nl_app_unlock(); if (name_changed) diff --git a/src/sys/nl_console.c b/src/sys/nl_console.c index 8317b4f..c3fc874 100644 --- a/src/sys/nl_console.c +++ b/src/sys/nl_console.c @@ -78,7 +78,7 @@ static void exec(const nl_cmd_t *c) nl_app_lock(); cfg->fade_in_ms = (uint16_t)c->arg[0]; cfg->fade_out_ms = (uint16_t)c->arg[1]; - nl_ctrl_set_fades(cfg->fade_in_ms, cfg->fade_out_ms); + nl_ctrl_set_options(cfg->fade_in_ms, cfg->fade_out_ms, cfg->transition_ms, cfg->wake_on_change != 0); nl_app_unlock(); printf(nl_app_save_settings() == 0 ? "ok\r\n" : "error: save failed\r\n"); print_state(); diff --git a/test/test_api.c b/test/test_api.c index 65014fb..ca25feb 100644 --- a/test/test_api.c +++ b/test/test_api.c @@ -55,6 +55,8 @@ static void test_get_state(void) TEST_ASSERT_EQUAL_STRING("last", cJSON_GetStringValue(field("power_on_behaviour"))); TEST_ASSERT_EQUAL_INT(700, field("fade_in")->valueint); TEST_ASSERT_EQUAL_INT(500, field("fade_out")->valueint); + TEST_ASSERT_EQUAL_INT(300, field("transition_default")->valueint); + TEST_ASSERT_TRUE(cJSON_IsFalse(field("wake_on_change"))); cap = field("capabilities"); TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItem(cap, "color"))); TEST_ASSERT_EQUAL_INT(2700, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 0)->valueint); @@ -114,6 +116,8 @@ static void test_post_validation(void) {"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"}, {"{\"fade_in\":10001}", "invalid fade_in"}, {"{\"fade_out\":-1}", "invalid fade_out"}, + {"{\"transition_default\":60001}", "invalid transition_default"}, + {"{\"wake_on_change\":\"ja\"}", "invalid wake_on_change"}, }; for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) { call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body); @@ -151,6 +155,33 @@ static void test_post_settings_fields(void) TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour"))); } +static void test_post_transition_default_and_wake(void) +{ + call(NL_HTTP_POST, "/api/v1/device/state", NULL, + "{\"transition_default\":1200,\"wake_on_change\":true}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_save_calls); + TEST_ASSERT_EQUAL_UINT16(1200, fake_settings.transition_ms); + TEST_ASSERT_EQUAL_UINT8(1, fake_settings.wake_on_change); + TEST_ASSERT_EQUAL_INT(1200, field("transition_default")->valueint); + TEST_ASSERT_TRUE(cJSON_IsTrue(field("wake_on_change"))); +} + +/* A colour and a per-call transition travel in the same request - the light change is + applied, and nothing is treated as a settings write. */ +static void test_post_color_with_transition(void) +{ + call(NL_HTTP_POST, "/api/v1/device/state", NULL, + "{\"color\":{\"r\":255,\"g\":0,\"b\":0},\"transition\":2000}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_apply_calls); + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); + TEST_ASSERT_TRUE(fake_last_change.has_transition); + TEST_ASSERT_EQUAL_UINT32(2000, fake_last_change.transition_ms); + TEST_ASSERT_TRUE(fake_last_change.has_color); + TEST_ASSERT_EQUAL_UINT8(255, fake_last_change.r); +} + static void test_post_save_failure(void) { fake_save_result = -1; @@ -364,6 +395,8 @@ int main(void) RUN_TEST(test_post_validation); RUN_TEST(test_post_control_errors); RUN_TEST(test_post_settings_fields); + RUN_TEST(test_post_transition_default_and_wake); + RUN_TEST(test_post_color_with_transition); RUN_TEST(test_post_save_failure); RUN_TEST(test_auth_all_protects_device_state); RUN_TEST(test_auth_too_long_is_unauthenticated); diff --git a/test/test_control.c b/test/test_control.c index 9adb124..4bfbd28 100644 --- a/test/test_control.c +++ b/test/test_control.c @@ -10,18 +10,24 @@ static void out(const uint8_t ch[NL_CH_COUNT], void *ctx) (void)ctx; } -static void start(const char *board_id, bool on) +static void start_opts(const char *board_id, bool on, uint16_t transition_ms, bool wake) { const nl_board_t *b = nl_board_find(board_id); nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL}; nl_engine_init(&cfg); - nl_ctrl_init(b, 500, 800); + nl_ctrl_init(b, 500, 800, transition_ms, wake); nl_light_state_t s = nl_boot_default_state(b); s.on = on; nl_ctrl_boot(&s); nl_engine_tick(10000); /* finish a possible boot fade */ } +/* Most tests want the historical behaviour: no default transition, no wake switch. */ +static void start(const char *board_id, bool on) +{ + start_opts(board_id, on, 0, false); +} + static uint16_t bri(void) { nl_visual_t v; @@ -188,13 +194,112 @@ static void test_boot_fades_in_when_on(void) const nl_board_t *b = nl_board_find("evk"); nl_engine_cfg_t cfg = {b->ct_min, b->ct_max, out, NULL}; nl_engine_init(&cfg); - nl_ctrl_init(b, 500, 800); + nl_ctrl_init(b, 500, 800, 0, false); nl_light_state_t s = nl_boot_default_state(b); nl_ctrl_boot(&s); nl_engine_tick(250); TEST_ASSERT_EQUAL_UINT16(5000, bri()); } +static void test_default_transition_applies_while_on(void) +{ + start_opts("evk", true, 400, false); + nl_light_change_t c = chg(); + c.has_brightness = true; + c.brightness = 50; + nl_ctrl_apply(&c, 0); + TEST_ASSERT_EQUAL_UINT16(10000, bri()); /* not instant any more */ + nl_engine_tick(200); + TEST_ASSERT_EQUAL_UINT16(7500, bri()); + nl_engine_tick(200); + TEST_ASSERT_EQUAL_UINT16(5000, bri()); +} + +static void test_explicit_transition_beats_default(void) +{ + start_opts("evk", true, 5000, false); + nl_light_change_t c = chg(); + c.has_brightness = true; + c.brightness = 50; + c.has_transition = true; + c.transition_ms = 100; + nl_ctrl_apply(&c, 0); + nl_engine_tick(100); + TEST_ASSERT_EQUAL_UINT16(5000, bri()); +} + +static void test_fades_beat_default_transition_when_switching(void) +{ + start_opts("evk", false, 5000, false); + nl_light_change_t c = chg(); + c.has_on = true; + c.on = true; + nl_ctrl_apply(&c, 0); + nl_engine_tick(500); /* fade_in, not the 5 s default */ + TEST_ASSERT_EQUAL_UINT16(10000, bri()); + + c = chg(); + c.has_on = true; + c.on = false; + nl_ctrl_apply(&c, 1000); + nl_engine_tick(800); /* fade_out */ + TEST_ASSERT_EQUAL_UINT16(0, bri()); +} + +static void test_wake_switch_off_leaves_lamp_off(void) +{ + start_opts("evk", false, 300, false); + nl_light_change_t c = chg(); + c.has_ct = true; + c.ct = 3000; + nl_ctrl_apply(&c, 0); + nl_light_state_t s; + nl_ctrl_get(&s); + TEST_ASSERT_FALSE(s.on); +} + +static void test_wake_switch_turns_lamp_on_with_fade_in(void) +{ + start_opts("evk", false, 300, true); + nl_light_change_t c = chg(); + c.has_ct = true; + c.ct = 3000; + nl_ctrl_apply(&c, 0); + nl_light_state_t s; + nl_ctrl_get(&s); + TEST_ASSERT_TRUE(s.on); + nl_engine_tick(250); /* fade_in = 500, not the 300 ms default transition */ + TEST_ASSERT_EQUAL_UINT16(5000, bri()); +} + +static void test_wake_switch_also_reacts_to_brightness(void) +{ + start_opts("evk", false, 300, true); + nl_light_change_t c = chg(); + c.has_brightness = true; + c.brightness = 40; + nl_ctrl_apply(&c, 0); + nl_light_state_t s; + nl_ctrl_get(&s); + TEST_ASSERT_TRUE(s.on); + TEST_ASSERT_EQUAL_UINT8(40, s.brightness); +} + +static void test_explicit_off_beats_wake_switch(void) +{ + start_opts("evk", false, 300, true); + nl_light_change_t c = chg(); + c.has_on = true; + c.on = false; + c.has_brightness = true; + c.brightness = 40; + nl_ctrl_apply(&c, 0); + nl_light_state_t s; + nl_ctrl_get(&s); + TEST_ASSERT_FALSE(s.on); + TEST_ASSERT_EQUAL_UINT8(40, s.brightness); +} + int main(void) { UNITY_BEGIN(); @@ -209,5 +314,12 @@ int main(void) RUN_TEST(test_persist_debounce); RUN_TEST(test_change_during_write_stays_dirty); RUN_TEST(test_boot_fades_in_when_on); + RUN_TEST(test_default_transition_applies_while_on); + RUN_TEST(test_explicit_transition_beats_default); + RUN_TEST(test_fades_beat_default_transition_when_switching); + RUN_TEST(test_wake_switch_off_leaves_lamp_off); + RUN_TEST(test_wake_switch_turns_lamp_on_with_fade_in); + RUN_TEST(test_wake_switch_also_reacts_to_brightness); + RUN_TEST(test_explicit_off_beats_wake_switch); return UNITY_END(); } diff --git a/test/test_settings.c b/test/test_settings.c index 4ccbbfe..37d6cc6 100644 --- a/test/test_settings.c +++ b/test/test_settings.c @@ -42,6 +42,8 @@ static void test_defaults(void) TEST_ASSERT_EQUAL_UINT8(NL_POB_LAST, s.power_on_behaviour); TEST_ASSERT_EQUAL_UINT16(500, s.fade_in_ms); TEST_ASSERT_EQUAL_UINT16(500, s.fade_out_ms); + TEST_ASSERT_EQUAL_UINT16(300, s.transition_ms); + TEST_ASSERT_EQUAL_UINT8(0, s.wake_on_change); } static void test_save_and_load(void) @@ -108,6 +110,19 @@ static void test_load_accepts_shorter_record(void) TEST_ASSERT_EQUAL_UINT16(500, out.fade_in_ms); TEST_ASSERT_EQUAL_UINT16(500, out.fade_out_ms); TEST_ASSERT_EQUAL_UINT8(0, out.ota_pending); + TEST_ASSERT_EQUAL_UINT16(300, out.transition_ms); + TEST_ASSERT_EQUAL_UINT8(0, out.wake_on_change); +} + +static void test_transition_and_wake_roundtrip(void) +{ + nl_settings_t in = sample("neu"), out; + in.transition_ms = 1500; + in.wake_on_change = 1; + TEST_ASSERT_EQUAL_INT(0, nl_settings_save(&in)); + TEST_ASSERT_TRUE(nl_settings_load(&out)); + TEST_ASSERT_EQUAL_UINT16(1500, out.transition_ms); + TEST_ASSERT_EQUAL_UINT8(1, out.wake_on_change); } static void test_load_rejects_longer_record(void) @@ -171,6 +186,7 @@ int main(void) { UNITY_BEGIN(); RUN_TEST(test_defaults); + RUN_TEST(test_transition_and_wake_roundtrip); RUN_TEST(test_save_and_load); RUN_TEST(test_alternates_slots); RUN_TEST(test_power_loss_keeps_previous);