feat(api): Settings-Typ standalone und wifi.standalone in system/info

This commit is contained in:
Michael Sauer
2026-10-02 00:10:38 +02:00
parent 8469b92338
commit 416307083f
5 changed files with 273 additions and 0 deletions
+66
View File
@@ -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";
+6
View File
@@ -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-<mac4>", 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 */
+23
View File
@@ -1,5 +1,6 @@
#include "fake_api_ctx.h"
#include <stdio.h>
#include <string.h>
#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;
+4
View File
@@ -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;
+174
View File
@@ -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();
}