From 416307083f9c057916269f25e713421bf9aee00b Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Fri, 2 Oct 2026 00:10:38 +0200 Subject: [PATCH] feat(api): Settings-Typ standalone und wifi.standalone in system/info --- src/net/nl_api.c | 66 +++++++++++++++++ src/net/nl_api.h | 6 ++ test/fake_api_ctx.c | 23 ++++++ test/fake_api_ctx.h | 4 + test/test_api.c | 174 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 273 insertions(+) diff --git a/src/net/nl_api.c b/src/net/nl_api.c index b68cddc..99ee2e5 100644 --- a/src/net/nl_api.c +++ b/src/net/nl_api.c @@ -259,6 +259,10 @@ static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp) cJSON_AddStringToObject(w, "error", in.wifi_error); else cJSON_AddNullToObject(w, "error"); + /* "mode" allein reicht nicht: im Standalone-Betrieb ist es "ap", genau wie beim offenen + * Onboarding-AP. Ohne dieses Feld boete die App einer fertig eingerichteten Lampe ein + * Onboarding an. */ + cJSON_AddBoolToObject(w, "standalone", in.wifi_standalone); cJSON_AddNumberToObject(o, "uptime", in.uptime_s); cJSON_AddNumberToObject(o, "free_heap", in.free_heap); if (in.last_ota_error) @@ -314,6 +318,16 @@ static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp cJSON_AddStringToObject(sa, "layout", s.sacn_layout ? "rgb" : "dimmer"); cJSON_AddNumberToObject(sa, "slots", nl_board_dmx_slots(ctx->board, s.sacn_layout != 0)); } + { + cJSON *sa = cJSON_AddObjectToObject(o, "standalone"); + cJSON_AddBoolToObject(sa, "enabled", s.standalone != 0); + /* Der WIRKSAME Name, nicht der gespeicherte: ohne eigenen Namen heisst der AP + * wie der Hostname. Wer hier einen leeren String saehe, muesste die Vorgabe + * selbst nachbilden. Das AP-Passwort steht bewusst nicht hier. */ + cJSON_AddStringToObject(sa, "ssid", s.ap_ssid[0] ? s.ap_ssid : (ctx->hostname ? ctx->hostname : "")); + cJSON_AddNumberToObject(sa, "channel", s.ap_channel ? s.ap_channel : 6); + cJSON_AddBoolToObject(sa, "pulse", s.standalone_pulse != 0); + } reply_json(resp, 200, o); } @@ -345,6 +359,58 @@ static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t cJSON_Delete(j); return; } + } else if (strcmp(type, "standalone") == 0) { + const cJSON *en = cJSON_GetObjectItemCaseSensitive(j, "enabled"); + const cJSON *pu = cJSON_GetObjectItemCaseSensitive(j, "pulse"); + const char *as = NULL, *ap = NULL; + long ch = 0; + int asp, app, chp; + bool will_enable; + + will_enable = cJSON_IsBool(en) && cJSON_IsTrue(en); + asp = get_string(j, "ssid", 1, NL_SSID_LEN - 1, &as); + app = get_string(j, "password", 8, NL_PASS_LEN - 1, &ap); + chp = get_int(j, "channel", 1, 13, &ch); + + if (!cJSON_IsBool(en)) { + err = "invalid enabled"; + } else if (asp < 0) { + err = "invalid ssid"; + } else if (app < 0) { + /* Die Meldung nennt das Feld, nie den Wert: ein zu kurzes Passwort darf + * nicht im Protokoll des Aufrufers landen. */ + err = "invalid password"; + } else if (chp < 0) { + err = "invalid channel"; + } else if (will_enable && app != 1 && s.ap_pass[0] == '\0') { + err = "password required"; + } else if (pu && !cJSON_IsBool(pu)) { + err = "invalid pulse"; + } else { + if (asp == 1) + strcpy(s.ap_ssid, as); + if (app == 1) + strcpy(s.ap_pass, ap); + if (chp == 1) + s.ap_channel = (uint8_t)ch; + if (pu) + s.standalone_pulse = cJSON_IsTrue(pu) ? 1 : 0; + s.standalone = will_enable ? 1 : 0; + /* Wie bei "wifi" kehrt dieser Zweig hier zurueck statt ans gemeinsame Ende zu + * laufen (das antwortet 200): der Wechsel zieht der Verbindung das Netz weg, die + * Antwort ist 202 und muss vorher ausgefuellt sein. */ + if (ctx->save_settings(&s) != 0) { + reply_error(resp, 500, "save failed"); + } else if (!ctx->standalone_configure || + ctx->standalone_configure(will_enable, s.ap_ssid, s.ap_pass, + s.ap_channel, s.standalone_pulse != 0) != 0) { + reply_error(resp, 503, "wifi busy"); + } else { + reply(resp, 202, "{\"status\":\"standalone\"}"); + } + cJSON_Delete(j); + return; + } } else if (strcmp(type, "device_name") == 0) { if (get_string(j, "name", 1, NL_NAME_LEN - 1, &a) != 1) err = "invalid name"; diff --git a/src/net/nl_api.h b/src/net/nl_api.h index 3d37c4e..d11b742 100644 --- a/src/net/nl_api.h +++ b/src/net/nl_api.h @@ -27,18 +27,24 @@ typedef struct { bool sacn_active; /* true, solange die Lampe ferngesteuert wird */ bool sacn_joined; /* Multicast-Gruppe des Universe beigetreten */ bool sacn_running; /* Empfangs-Task lebt und haelt einen Socket */ + bool wifi_standalone; /* eigener Dauer-AP, nicht der offene Onboarding-AP */ } nl_sys_info_t; /* Everything the API needs from the application; implemented by src/sys/nl_app_api.c. */ typedef struct { const nl_board_t *board; const char *fw_version; + const char *hostname; /* "ninlight-", Vorgabe fuer den AP-Namen */ const char *build_date; void (*get_state)(nl_light_state_t *out); nl_ctrl_err_t (*apply)(const nl_light_change_t *c); void (*get_settings)(nl_settings_t *out); int (*save_settings)(const nl_settings_t *s); /* 0 = ok; wifi fields are ignored */ int (*wifi_configure)(const char *ssid, const char *pass); /* 0 = onboarding test started */ + /* 0 = uebernommen (Antwort 202), sonst 503. Wie wifi_configure wirkt der Wechsel + erst nach der Antwort - die Verbindung bricht sonst vor dem Senden weg. */ + int (*standalone_configure)(bool enabled, const char *ssid, const char *pass, + uint8_t channel, bool pulse); bool (*open_access)(void); /* unconfigured onboarding AP */ void (*get_info)(nl_sys_info_t *out); void (*factory_reset)(void); /* request only */ diff --git a/test/fake_api_ctx.c b/test/fake_api_ctx.c index 2e6d5d6..c9ddc1c 100644 --- a/test/fake_api_ctx.c +++ b/test/fake_api_ctx.c @@ -1,5 +1,6 @@ #include "fake_api_ctx.h" +#include #include #include "light/nl_boot_state.h" @@ -12,6 +13,10 @@ nl_ctrl_err_t fake_apply_result; int fake_apply_calls, fake_save_calls, fake_save_result; int fake_wifi_calls, fake_wifi_result; char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN]; +int fake_standalone_calls, fake_standalone_result; +bool fake_standalone_enabled, fake_standalone_pulse; +char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN]; +uint8_t fake_standalone_channel; int fake_factory_calls, fake_ping_calls, fake_fw_result; char fake_fw_url[256]; bool fake_open; @@ -49,6 +54,18 @@ static int wifi_configure(const char *ssid, const char *pass) return fake_wifi_result; } +static int fake_standalone_configure(bool enabled, const char *ssid, const char *pass, + uint8_t channel, bool pulse) +{ + fake_standalone_calls++; + fake_standalone_enabled = enabled; + fake_standalone_pulse = pulse; + fake_standalone_channel = channel; + snprintf(fake_standalone_ssid, sizeof fake_standalone_ssid, "%s", ssid ? ssid : ""); + snprintf(fake_standalone_pass, sizeof fake_standalone_pass, "%s", pass ? pass : ""); + return fake_standalone_result; +} + static bool open_access(void) { return fake_open; } static void get_info(nl_sys_info_t *out) { *out = fake_info; } static void factory_reset(void) { fake_factory_calls++; } @@ -83,6 +100,10 @@ void fake_api_reset(const char *board_id) fake_apply_calls = fake_save_calls = fake_save_result = 0; fake_wifi_calls = fake_wifi_result = 0; fake_wifi_ssid[0] = fake_wifi_pass[0] = '\0'; + fake_standalone_calls = fake_standalone_result = 0; + fake_standalone_enabled = fake_standalone_pulse = false; + fake_standalone_ssid[0] = fake_standalone_pass[0] = '\0'; + fake_standalone_channel = 0; fake_factory_calls = fake_ping_calls = 0; fake_fw_result = -1; fake_fw_url[0] = '\0'; @@ -96,6 +117,8 @@ void fake_api_reset(const char *board_id) fake_ctx.get_settings = get_settings; fake_ctx.save_settings = save_settings; fake_ctx.wifi_configure = wifi_configure; + fake_ctx.standalone_configure = fake_standalone_configure; + fake_ctx.hostname = "ninlight-681f"; fake_ctx.open_access = open_access; fake_ctx.get_info = get_info; fake_ctx.factory_reset = factory_reset; diff --git a/test/fake_api_ctx.h b/test/fake_api_ctx.h index 9abfebd..0aabcc9 100644 --- a/test/fake_api_ctx.h +++ b/test/fake_api_ctx.h @@ -12,6 +12,10 @@ extern nl_ctrl_err_t fake_apply_result; extern int fake_apply_calls, fake_save_calls, fake_save_result; extern int fake_wifi_calls, fake_wifi_result; extern char fake_wifi_ssid[NL_SSID_LEN], fake_wifi_pass[NL_PASS_LEN]; +extern int fake_standalone_calls, fake_standalone_result; +extern bool fake_standalone_enabled, fake_standalone_pulse; +extern char fake_standalone_ssid[NL_SSID_LEN], fake_standalone_pass[NL_PASS_LEN]; +extern uint8_t fake_standalone_channel; extern int fake_factory_calls, fake_ping_calls, fake_fw_result; extern char fake_fw_url[256]; extern bool fake_open; diff --git a/test/test_api.c b/test/test_api.c index 6abb14d..565b1ce 100644 --- a/test/test_api.c +++ b/test/test_api.c @@ -589,6 +589,169 @@ static void test_put_sacn_layout_and_start_slot_together(void) TEST_ASSERT_EQUAL_UINT16(510, fake_settings.sacn_start_slot); } +/* --- standalone --- */ + +static void test_standalone_requires_a_password(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("password required", error_text()); + TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls); + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); +} + +/* Ein gespeichertes Passwort genuegt - man soll den Kanal aendern koennen, ohne das + * Passwort erneut zu schicken. */ +static void test_standalone_keeps_a_stored_password(void) +{ + strcpy(fake_settings.ap_pass, "geheim123"); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"standalone\",\"enabled\":true,\"channel\":11}"); + TEST_ASSERT_EQUAL_INT(202, resp.status); + TEST_ASSERT_EQUAL_INT(11, fake_standalone_channel); + TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass); +} + +static void test_standalone_rejects_a_short_password(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"1234567\"}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("invalid password", error_text()); + /* Die Fehlermeldung nennt das Feld, nie den Wert. */ + TEST_ASSERT_NULL(strstr(resp.body, "1234567")); + TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls); +} + +static void test_standalone_rejects_a_channel_outside_the_band(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":14}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\",\"channel\":0}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls); +} + +static void test_standalone_accepts_a_full_request(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"standalone\",\"enabled\":true,\"password\":\"geheim123\"," + "\"ssid\":\"Wohnzimmerlampe\",\"channel\":6,\"pulse\":false}"); + TEST_ASSERT_EQUAL_INT(202, resp.status); + TEST_ASSERT_EQUAL_STRING("standalone", cJSON_GetStringValue(field("status"))); + TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls); + TEST_ASSERT_TRUE(fake_standalone_enabled); + TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", fake_standalone_ssid); + TEST_ASSERT_EQUAL_STRING("geheim123", fake_standalone_pass); + TEST_ASSERT_EQUAL_INT(6, fake_standalone_channel); + TEST_ASSERT_FALSE(fake_standalone_pulse); + /* Gespeichert wird auch: sonst ist der Modus nach dem Neustart weg. */ + TEST_ASSERT_EQUAL_INT(1, fake_save_calls); + TEST_ASSERT_EQUAL_UINT8(1, fake_settings.standalone); + TEST_ASSERT_EQUAL_STRING("geheim123", fake_settings.ap_pass); +} + +/* Abschalten braucht kein Passwort. */ +static void test_standalone_can_be_switched_off(void) +{ + fake_settings.standalone = 1; + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":false}"); + TEST_ASSERT_EQUAL_INT(202, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_standalone_calls); + TEST_ASSERT_FALSE(fake_standalone_enabled); + TEST_ASSERT_EQUAL_UINT8(0, fake_settings.standalone); +} + +static void test_standalone_reports_a_busy_radio_and_a_failed_save(void) +{ + strcpy(fake_settings.ap_pass, "geheim123"); + fake_standalone_result = -1; + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}"); + TEST_ASSERT_EQUAL_INT(503, resp.status); + TEST_ASSERT_EQUAL_STRING("wifi busy", error_text()); + + fake_standalone_result = 0; + fake_standalone_calls = 0; + fake_save_result = -1; + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true}"); + TEST_ASSERT_EQUAL_INT(500, resp.status); + /* Ohne gespeicherten Datensatz darf der Wechsel nicht angestossen werden. */ + TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls); +} + +static void test_standalone_rejects_bad_types_of_the_other_fields(void) +{ + strcpy(fake_settings.ap_pass, "geheim123"); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":\"yes\"}"); + TEST_ASSERT_EQUAL_STRING("invalid enabled", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"pulse\":1}"); + TEST_ASSERT_EQUAL_STRING("invalid pulse", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"standalone\",\"enabled\":true,\"ssid\":\"\"}"); + TEST_ASSERT_EQUAL_STRING("invalid ssid", error_text()); + TEST_ASSERT_EQUAL_INT(0, fake_standalone_calls); +} + +/* Das AP-Passwort darf nirgends in der Antwort auftauchen. */ +static void test_settings_show_standalone_without_the_password(void) +{ + cJSON *o, *sa; + fake_settings.standalone = 1; + fake_settings.ap_channel = 11; + fake_settings.standalone_pulse = 0; + strcpy(fake_settings.ap_ssid, "Wohnzimmerlampe"); + strcpy(fake_settings.ap_pass, "geheim123"); + call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_NULL(strstr(resp.body, "geheim123")); + o = cJSON_Parse(resp.body); + sa = cJSON_GetObjectItemCaseSensitive(o, "standalone"); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "enabled"))); + TEST_ASSERT_EQUAL_STRING("Wohnzimmerlampe", + cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid"))); + TEST_ASSERT_EQUAL_INT(11, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel"))); + TEST_ASSERT_FALSE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(sa, "pulse"))); + cJSON_Delete(o); +} + +/* Ohne eigenen Namen zeigt die Antwort den WIRKSAMEN, nicht einen leeren String - + * sonst muesste jeder Aufrufer die Vorgabe selbst nachbilden. */ +static void test_settings_show_the_effective_ap_name(void) +{ + cJSON *o, *sa; + fake_settings.ap_ssid[0] = '\0'; + call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL); + o = cJSON_Parse(resp.body); + sa = cJSON_GetObjectItemCaseSensitive(o, "standalone"); + TEST_ASSERT_EQUAL_STRING("ninlight-681f", + cJSON_GetStringValue(cJSON_GetObjectItemCaseSensitive(sa, "ssid"))); + /* Kanal 0 im Datensatz heisst 6. */ + TEST_ASSERT_EQUAL_INT(6, (int)cJSON_GetNumberValue(cJSON_GetObjectItemCaseSensitive(sa, "channel"))); + cJSON_Delete(o); +} + +/* "ap" allein reicht nicht: denselben Wert meldet der Onboarding-AP. Ohne dieses Feld + * boete die App einer fertig eingerichteten Lampe ein Onboarding an. */ +static void test_info_tells_standalone_from_onboarding(void) +{ + cJSON *o, *w; + fake_info.wifi_mode = "ap"; + fake_info.wifi_standalone = true; + call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL); + o = cJSON_Parse(resp.body); + w = cJSON_GetObjectItemCaseSensitive(o, "wifi"); + TEST_ASSERT_TRUE(cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(w, "standalone"))); + cJSON_Delete(o); + + fake_info.wifi_standalone = false; + call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL); + o = cJSON_Parse(resp.body); + w = cJSON_GetObjectItemCaseSensitive(o, "wifi"); + TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItemCaseSensitive(w, "standalone"))); + cJSON_Delete(o); +} + int main(void) { UNITY_BEGIN(); @@ -627,5 +790,16 @@ int main(void) RUN_TEST(test_info_exposes_sacn_state); RUN_TEST(test_info_reports_a_dead_sacn_task); RUN_TEST(test_commands); + RUN_TEST(test_standalone_requires_a_password); + RUN_TEST(test_standalone_keeps_a_stored_password); + RUN_TEST(test_standalone_rejects_a_short_password); + RUN_TEST(test_standalone_rejects_a_channel_outside_the_band); + RUN_TEST(test_standalone_accepts_a_full_request); + RUN_TEST(test_standalone_can_be_switched_off); + RUN_TEST(test_standalone_reports_a_busy_radio_and_a_failed_save); + RUN_TEST(test_standalone_rejects_bad_types_of_the_other_fields); + RUN_TEST(test_settings_show_standalone_without_the_password); + RUN_TEST(test_settings_show_the_effective_ap_name); + RUN_TEST(test_info_tells_standalone_from_onboarding); return UNITY_END(); }