diff --git a/CMakeLists.txt b/CMakeLists.txt index 46bf1df..0cf3065 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -46,6 +46,7 @@ set(NL_FW_SOURCES src/sys/sdk_stubs.c src/net/nl_names.c src/net/nl_http.c + src/net/nl_api.c third_party/cJSON/cJSON.c) set(NL_FW ninlight_${NINLIGHT_DEVICE}) diff --git a/src/net/nl_api.c b/src/net/nl_api.c new file mode 100644 index 0000000..0577003 --- /dev/null +++ b/src/net/nl_api.c @@ -0,0 +1,391 @@ +#include "net/nl_api.h" + +#include +#include + +#include "cJSON.h" + +#define P_STATE "/api/v1/device/state" +#define P_INFO "/api/v1/system/info" +#define P_SETTINGS "/api/v1/system/settings" +#define P_COMMAND "/api/v1/system/command" + +static const char *const POB_NAMES[] = {"last", "on", "off"}; + +static void reply(nl_api_response_t *r, int status, const char *json) +{ + r->status = status; + snprintf(r->body, sizeof r->body, "%s", json); +} + +static void reply_error(nl_api_response_t *r, int status, const char *msg) +{ + r->status = status; + snprintf(r->body, sizeof r->body, "{\"error\":\"%s\"}", msg); +} + +/* Takes ownership of obj. */ +static void reply_json(nl_api_response_t *r, int status, cJSON *obj) +{ + if (obj && cJSON_PrintPreallocated(obj, r->body, (int)sizeof r->body, 0)) + r->status = status; + else + reply_error(r, 500, "response too large"); + cJSON_Delete(obj); +} + +/* Compares in time independent of where the first mismatch is. */ +static bool token_ok(const char *given, const char *expected) +{ + size_t lg = strlen(given), le = strlen(expected); + unsigned diff = (lg != le || le == 0) ? 1u : 0u; + for (size_t i = 0; i < NL_TOKEN_LEN - 1; i++) { + unsigned char a = i < lg ? (unsigned char)given[i] : 0; + unsigned char b = i < le ? (unsigned char)expected[i] : 0; + diff |= (unsigned)(a ^ b); + } + return diff == 0; +} + +/* 1 = present and valid, 0 = absent, -1 = present but invalid */ +static int get_int(const cJSON *obj, const char *name, double lo, double hi, long *out) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, name); + if (!v) + return 0; + if (!cJSON_IsNumber(v) || v->valuedouble != (double)(long)v->valuedouble || v->valuedouble < lo || + v->valuedouble > hi) + return -1; + *out = (long)v->valuedouble; + return 1; +} + +static int get_string(const cJSON *obj, const char *name, size_t min, size_t max, const char **out) +{ + const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, name); + size_t n; + if (!v) + return 0; + if (!cJSON_IsString(v) || !v->valuestring) + return -1; + n = strlen(v->valuestring); + if (n < min || n > max) + return -1; + *out = v->valuestring; + return 1; +} + +static cJSON *parse_body(const nl_http_request_t *req, nl_api_response_t *resp) +{ + cJSON *j = req->body_len ? cJSON_ParseWithLength(req->body, req->body_len) : NULL; + if (!j || !cJSON_IsObject(j)) { + cJSON_Delete(j); + reply_error(resp, 400, "invalid json"); + return NULL; + } + return j; +} + +static cJSON *state_json(const nl_api_ctx_t *ctx) +{ + nl_light_state_t st; + nl_settings_t s; + cJSON *o, *c, *cap; + int ct[2]; + + ctx->get_state(&st); + ctx->get_settings(&s); + o = cJSON_CreateObject(); + cJSON_AddBoolToObject(o, "on", st.on); + cJSON_AddNumberToObject(o, "brightness", st.brightness); + cJSON_AddStringToObject(o, "mode", st.mode == NL_MODE_COLOR ? "color" : "white"); + c = cJSON_AddObjectToObject(o, "color"); + cJSON_AddNumberToObject(c, "r", st.r); + cJSON_AddNumberToObject(c, "g", st.g); + cJSON_AddNumberToObject(c, "b", st.b); + cJSON_AddNumberToObject(o, "color_temp", st.ct); + cJSON_AddStringToObject(o, "power_on_behaviour", + 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); + cap = cJSON_AddObjectToObject(o, "capabilities"); + cJSON_AddBoolToObject(cap, "color", ctx->board->has_rgb); + ct[0] = ctx->board->ct_min; + ct[1] = ctx->board->ct_max; + cJSON_AddItemToObject(cap, "color_temp", cJSON_CreateIntArray(ct, 2)); + return o; +} + +static void handle_post_state(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) +{ + cJSON *j = parse_body(req, resp); + const cJSON *it; + nl_light_change_t ch; + nl_settings_t s; + const char *err = NULL; + bool light = false, settings = false; + long v, r, g, b; + + if (!j) + return; + memset(&ch, 0, sizeof ch); + ctx->get_settings(&s); + + it = cJSON_GetObjectItemCaseSensitive(j, "on"); + if (it) { + if (!cJSON_IsBool(it)) { err = "invalid on"; goto out; } + ch.has_on = true; + ch.on = cJSON_IsTrue(it) != 0; + light = true; + } + switch (get_int(j, "brightness", 1, 100, &v)) { + case -1: err = "invalid brightness"; goto out; + case 1: ch.has_brightness = true; ch.brightness = (uint8_t)v; light = true; break; + default: break; + } + it = cJSON_GetObjectItemCaseSensitive(j, "color"); + if (it) { + if (!cJSON_IsObject(it) || get_int(it, "r", 0, 255, &r) != 1 || get_int(it, "g", 0, 255, &g) != 1 || + get_int(it, "b", 0, 255, &b) != 1) { + err = "invalid color"; + goto out; + } + ch.has_color = true; + ch.r = (uint8_t)r; + ch.g = (uint8_t)g; + ch.b = (uint8_t)b; + light = true; + } + switch (get_int(j, "color_temp", 1000, 20000, &v)) { + case -1: err = "invalid color_temp"; goto out; + case 1: ch.has_ct = true; ch.ct = (uint16_t)v; light = true; break; + default: break; + } + switch (get_int(j, "transition", 0, NL_MAX_TRANSITION_MS, &v)) { + case -1: err = "invalid transition"; goto out; + case 1: ch.has_transition = true; ch.transition_ms = (uint32_t)v; break; + default: break; + } + it = cJSON_GetObjectItemCaseSensitive(j, "power_on_behaviour"); + if (it) { + int idx = -1; + for (int i = 0; i < 3 && cJSON_IsString(it); i++) + if (strcmp(it->valuestring, POB_NAMES[i]) == 0) + idx = i; + if (idx < 0) { err = "invalid power_on_behaviour"; goto out; } + s.power_on_behaviour = (uint8_t)idx; + settings = true; + } + switch (get_int(j, "fade_in", 0, NL_MAX_FADE_MS, &v)) { + case -1: err = "invalid fade_in"; goto out; + case 1: s.fade_in_ms = (uint16_t)v; settings = true; break; + default: break; + } + switch (get_int(j, "fade_out", 0, NL_MAX_FADE_MS, &v)) { + case -1: err = "invalid fade_out"; goto out; + case 1: s.fade_out_ms = (uint16_t)v; settings = true; break; + default: break; + } + + if (light) { + switch (ctx->apply(&ch)) { + case NL_CTRL_OK: break; + case NL_CTRL_ERR_NO_RGB: err = "color not supported"; goto out; + case NL_CTRL_ERR_COLOR_AND_CT: err = "color and color_temp are exclusive"; goto out; + case NL_CTRL_ERR_BRIGHTNESS: err = "invalid brightness"; goto out; + default: err = "invalid transition"; goto out; + } + } + if (settings && ctx->save_settings(&s) != 0) { + cJSON_Delete(j); + reply_error(resp, 500, "save failed"); + return; + } +out: + cJSON_Delete(j); + if (err) + reply_error(resp, 400, err); + else + reply_json(resp, 200, state_json(ctx)); +} + +static void handle_info(const nl_api_ctx_t *ctx, nl_api_response_t *resp) +{ + nl_sys_info_t in; + cJSON *o, *w; + + memset(&in, 0, sizeof in); + ctx->get_info(&in); + o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "device_type", ctx->board->id); + cJSON_AddStringToObject(o, "product_name", ctx->board->name); + cJSON_AddStringToObject(o, "fw_version", ctx->fw_version); + cJSON_AddStringToObject(o, "build_date", ctx->build_date); + cJSON_AddStringToObject(o, "mac", in.mac); + cJSON_AddStringToObject(o, "name", in.name); + w = cJSON_AddObjectToObject(o, "wifi"); + cJSON_AddStringToObject(w, "mode", in.wifi_mode ? in.wifi_mode : "sta"); + cJSON_AddStringToObject(w, "ssid", in.ssid); + cJSON_AddNumberToObject(w, "rssi", in.rssi); + cJSON_AddStringToObject(w, "ip", in.ip); + if (in.sta_ip[0]) + cJSON_AddStringToObject(w, "sta_ip", in.sta_ip); + else + cJSON_AddNullToObject(w, "sta_ip"); + if (in.wifi_error) + cJSON_AddStringToObject(w, "error", in.wifi_error); + else + cJSON_AddNullToObject(w, "error"); + cJSON_AddNumberToObject(o, "uptime", in.uptime_s); + cJSON_AddNumberToObject(o, "free_heap", in.free_heap); + cJSON_AddStringToObject(o, "reset_reason", "unknown"); + if (in.last_ota_error) + cJSON_AddStringToObject(o, "last_ota_error", in.last_ota_error); + else + cJSON_AddNullToObject(o, "last_ota_error"); + reply_json(resp, 200, o); +} + +static void handle_get_settings(const nl_api_ctx_t *ctx, nl_api_response_t *resp) +{ + nl_settings_t s; + cJSON *o, *w; + + ctx->get_settings(&s); + o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "name", s.name); + w = cJSON_AddObjectToObject(o, "wifi"); + cJSON_AddStringToObject(w, "ssid", s.wifi_ssid); + cJSON_AddBoolToObject(o, "auth_all", s.auth_all != 0); + reply_json(resp, 200, o); +} + +static void handle_put_settings(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) +{ + cJSON *j = parse_body(req, resp); + const char *type = NULL, *a = NULL, *b = NULL, *err = NULL; + nl_settings_t s; + int pr = 0; + + if (!j) + return; + ctx->get_settings(&s); + if (get_string(j, "type", 1, 32, &type) != 1) { + err = "invalid type"; + } else if (strcmp(type, "wifi") == 0) { + if (get_string(j, "ssid", 1, NL_SSID_LEN - 1, &a) != 1) { + err = "invalid ssid"; + } else if ((pr = get_string(j, "password", 0, NL_PASS_LEN - 1, &b)) < 0) { + err = "invalid password"; + } else { + if (ctx->wifi_configure(a, pr == 1 ? b : "") != 0) + reply_error(resp, 503, "wifi busy"); + else + reply(resp, 202, "{\"status\":\"connecting\"}"); + 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"; + else + strcpy(s.name, a); + } else if (strcmp(type, "token") == 0) { + if (get_string(j, "token", 8, NL_TOKEN_LEN - 1, &a) != 1) + err = "invalid token"; + else + strcpy(s.token, a); + } else if (strcmp(type, "auth") == 0) { + const cJSON *all = cJSON_GetObjectItemCaseSensitive(j, "all"); + if (!cJSON_IsBool(all)) + err = "invalid all"; + else + s.auth_all = cJSON_IsTrue(all) ? 1 : 0; + } else { + err = "invalid type"; + } + cJSON_Delete(j); + if (err) + reply_error(resp, 400, err); + else if (ctx->save_settings(&s) != 0) + reply_error(resp, 500, "save failed"); + else + reply(resp, 200, "{\"ok\":true}"); +} + +static void handle_command(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) +{ + cJSON *j = parse_body(req, resp); + const cJSON *cmd; + const char *type = NULL, *url = NULL; + + if (!j) + return; + cmd = cJSON_GetObjectItemCaseSensitive(j, "command"); + if (!cJSON_IsObject(cmd) || get_string(cmd, "type", 1, 32, &type) != 1) { + reply_error(resp, 400, "invalid command"); + } else if (strcmp(type, "fw_update") == 0) { + if (get_string(cmd, "url", 8, 255, &url) != 1 || strncmp(url, "http://", 7) != 0) + reply_error(resp, 400, "invalid url"); + else if (ctx->fw_update(url) != 0) + reply_error(resp, 501, "fw_update not available"); + else + reply(resp, 202, "{\"status\":\"updating\"}"); + } else if (strcmp(type, "reboot") == 0) { + reply(resp, 200, "{\"ok\":true}"); + resp->after = NL_API_AFTER_REBOOT; + } else if (strcmp(type, "factory_reset") == 0) { + ctx->factory_reset(); + reply(resp, 200, "{\"ok\":true}"); + } else if (strcmp(type, "ping") == 0) { + ctx->ping(); + reply(resp, 200, "{\"ok\":true}"); + } else { + reply_error(resp, 400, "invalid command"); + } + cJSON_Delete(j); +} + +void nl_api_handle(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp) +{ + nl_settings_t s; + bool authed; + + memset(resp, 0, sizeof *resp); + ctx->get_settings(&s); + authed = ctx->open_access() || token_ok(req->auth, s.token); + + if (strcmp(req->path, P_STATE) == 0) { + if (s.auth_all && !authed) + reply_error(resp, 401, "unauthorized"); + else if (req->method == NL_HTTP_GET) + reply_json(resp, 200, state_json(ctx)); + else if (req->method == NL_HTTP_POST) + handle_post_state(ctx, req, resp); + else + reply_error(resp, 405, "method not allowed"); + } else if (strcmp(req->path, P_INFO) == 0) { + if (req->method == NL_HTTP_GET) + handle_info(ctx, resp); + else + reply_error(resp, 405, "method not allowed"); + } else if (strcmp(req->path, P_SETTINGS) == 0) { + if (!authed) + reply_error(resp, 401, "unauthorized"); + else if (req->method == NL_HTTP_GET) + handle_get_settings(ctx, resp); + else if (req->method == NL_HTTP_PUT) + handle_put_settings(ctx, req, resp); + else + reply_error(resp, 405, "method not allowed"); + } else if (strcmp(req->path, P_COMMAND) == 0) { + if (!authed) + reply_error(resp, 401, "unauthorized"); + else if (req->method == NL_HTTP_PUT) + handle_command(ctx, req, resp); + else + reply_error(resp, 405, "method not allowed"); + } else { + reply_error(resp, 404, "not found"); + } +} diff --git a/src/net/nl_api.h b/src/net/nl_api.h new file mode 100644 index 0000000..83980ee --- /dev/null +++ b/src/net/nl_api.h @@ -0,0 +1,56 @@ +#ifndef NL_API_H +#define NL_API_H + +#include +#include + +#include "board/nl_board.h" +#include "config/nl_settings.h" +#include "light/nl_control.h" +#include "light/nl_light_types.h" +#include "net/nl_http.h" + +#define NL_API_BODY_MAX 1024 + +typedef struct { + char mac[18]; + char name[NL_NAME_LEN]; + const char *wifi_mode; /* "sta" | "ap" | "apsta" */ + char ssid[NL_SSID_LEN]; + int rssi; /* dBm, 0 when not connected */ + char ip[16]; /* address the device answers on, "" if none */ + char sta_ip[16]; /* station address, "" when not connected (reported as null) */ + const char *wifi_error; /* NULL | "auth" | "not_found" | "timeout" */ + uint32_t uptime_s; + uint32_t free_heap; + const char *last_ota_error; /* NULL until OTA exists */ +} 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 *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 */ + bool (*open_access)(void); /* unconfigured onboarding AP */ + void (*get_info)(nl_sys_info_t *out); + void (*factory_reset)(void); /* request only */ + void (*ping)(void); + int (*fw_update)(const char *url); /* 0 = started, <0 = unavailable */ +} nl_api_ctx_t; + +typedef enum { NL_API_AFTER_NONE = 0, NL_API_AFTER_REBOOT } nl_api_after_t; + +typedef struct { + int status; + nl_api_after_t after; /* action for the server once the response has been sent */ + char body[NL_API_BODY_MAX]; +} nl_api_response_t; + +void nl_api_handle(const nl_api_ctx_t *ctx, const nl_http_request_t *req, nl_api_response_t *resp); + +#endif diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 6862723..57924ba 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -42,3 +42,6 @@ nl_test(test_reset_counter test_reset_counter.c fake_flash.c ${NL_SRC}/config/nl nl_test(test_console_cmd test_console_cmd.c ${NL_SRC}/sys/nl_console_cmd.c) nl_test(test_names test_names.c ${NL_SRC}/net/nl_names.c) nl_test(test_http test_http.c ${NL_SRC}/net/nl_http.c) +nl_test(test_api test_api.c fake_api_ctx.c ${NL_SRC}/net/nl_api.c ${NL_SRC}/board/nl_board.c + ${NL_SRC}/light/nl_boot_state.c ${NL_SRC}/config/nl_settings.c ${NL_SRC}/util/nl_crc.c fake_flash.c) +target_link_libraries(test_api PRIVATE nl_cjson) diff --git a/test/fake_api_ctx.c b/test/fake_api_ctx.c new file mode 100644 index 0000000..2e6d5d6 --- /dev/null +++ b/test/fake_api_ctx.c @@ -0,0 +1,104 @@ +#include "fake_api_ctx.h" + +#include + +#include "light/nl_boot_state.h" + +nl_light_state_t fake_state; +nl_settings_t fake_settings; +nl_sys_info_t fake_info; +nl_light_change_t fake_last_change; +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_factory_calls, fake_ping_calls, fake_fw_result; +char fake_fw_url[256]; +bool fake_open; + +static void get_state(nl_light_state_t *out) { *out = fake_state; } + +static nl_ctrl_err_t apply(const nl_light_change_t *c) +{ + fake_apply_calls++; + fake_last_change = *c; + if (fake_apply_result != NL_CTRL_OK) + return fake_apply_result; + if (c->has_on) fake_state.on = c->on; + if (c->has_brightness) fake_state.brightness = c->brightness; + if (c->has_color) { fake_state.r = c->r; fake_state.g = c->g; fake_state.b = c->b; fake_state.mode = NL_MODE_COLOR; } + if (c->has_ct) { fake_state.ct = c->ct; fake_state.mode = NL_MODE_WHITE; } + return NL_CTRL_OK; +} + +static void get_settings(nl_settings_t *out) { *out = fake_settings; } + +static int save_settings(const nl_settings_t *s) +{ + fake_save_calls++; + if (fake_save_result == 0) + fake_settings = *s; + return fake_save_result; +} + +static int wifi_configure(const char *ssid, const char *pass) +{ + fake_wifi_calls++; + strncpy(fake_wifi_ssid, ssid, sizeof fake_wifi_ssid - 1); + strncpy(fake_wifi_pass, pass, sizeof fake_wifi_pass - 1); + return fake_wifi_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++; } +static void ping(void) { fake_ping_calls++; } + +static int fw_update(const char *url) +{ + strncpy(fake_fw_url, url, sizeof fake_fw_url - 1); + return fake_fw_result; +} + +nl_api_ctx_t fake_ctx; + +void fake_api_reset(const char *board_id) +{ + const nl_board_t *b = nl_board_find(board_id); + fake_state = nl_boot_default_state(b); + nl_settings_defaults(&fake_settings); + strcpy(fake_settings.name, "Wohnzimmer"); + strcpy(fake_settings.wifi_ssid, "ninHome"); + memset(&fake_info, 0, sizeof fake_info); + strcpy(fake_info.mac, "28:6d:cd:5c:68:1f"); + strcpy(fake_info.name, "Wohnzimmer"); + fake_info.wifi_mode = "sta"; + strcpy(fake_info.ssid, "ninHome"); + fake_info.rssi = -58; + strcpy(fake_info.ip, "192.168.1.50"); + fake_info.uptime_s = 123; + fake_info.free_heap = 40000; + memset(&fake_last_change, 0, sizeof fake_last_change); + fake_apply_result = NL_CTRL_OK; + 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_factory_calls = fake_ping_calls = 0; + fake_fw_result = -1; + fake_fw_url[0] = '\0'; + fake_open = false; + + fake_ctx.board = b; + fake_ctx.fw_version = "1.2.3"; + fake_ctx.build_date = "2026-09-11"; + fake_ctx.get_state = get_state; + fake_ctx.apply = apply; + fake_ctx.get_settings = get_settings; + fake_ctx.save_settings = save_settings; + fake_ctx.wifi_configure = wifi_configure; + fake_ctx.open_access = open_access; + fake_ctx.get_info = get_info; + fake_ctx.factory_reset = factory_reset; + fake_ctx.ping = ping; + fake_ctx.fw_update = fw_update; +} diff --git a/test/fake_api_ctx.h b/test/fake_api_ctx.h new file mode 100644 index 0000000..9abfebd --- /dev/null +++ b/test/fake_api_ctx.h @@ -0,0 +1,21 @@ +#ifndef FAKE_API_CTX_H +#define FAKE_API_CTX_H + +#include "net/nl_api.h" + +extern nl_api_ctx_t fake_ctx; +extern nl_light_state_t fake_state; +extern nl_settings_t fake_settings; +extern nl_sys_info_t fake_info; +extern nl_light_change_t fake_last_change; +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_factory_calls, fake_ping_calls, fake_fw_result; +extern char fake_fw_url[256]; +extern bool fake_open; + +void fake_api_reset(const char *board_id); + +#endif diff --git a/test/test_api.c b/test/test_api.c new file mode 100644 index 0000000..794d609 --- /dev/null +++ b/test/test_api.c @@ -0,0 +1,338 @@ +#include +#include "unity.h" +#include "cJSON.h" +#include "fake_api_ctx.h" + +#define TOKEN "aabbccddeeff0011223344" + +static nl_api_response_t resp; +static cJSON *doc; + +static void call(nl_http_method_t m, const char *path, const char *auth, const char *body) +{ + nl_http_request_t r; + memset(&r, 0, sizeof r); + r.method = m; + strcpy(r.path, path); + strcpy(r.auth, auth ? auth : ""); + r.body = body; + r.body_len = body ? strlen(body) : 0; + nl_api_handle(&fake_ctx, &r, &resp); + cJSON_Delete(doc); + doc = cJSON_Parse(resp.body); +} + +static const cJSON *field(const char *name) { return cJSON_GetObjectItemCaseSensitive(doc, name); } +static const char *error_text(void) { return cJSON_GetStringValue(field("error")); } + +void setUp(void) { fake_api_reset("evk"); doc = NULL; } +void tearDown(void) { cJSON_Delete(doc); doc = NULL; } + +/* --- routing --- */ +static void test_unknown_path_and_method(void) +{ + call(NL_HTTP_GET, "/api/v1/nope", NULL, NULL); + TEST_ASSERT_EQUAL_INT(404, resp.status); + TEST_ASSERT_EQUAL_STRING("not found", error_text()); + call(NL_HTTP_PUT, "/api/v1/device/state", NULL, "{}"); + TEST_ASSERT_EQUAL_INT(405, resp.status); + call(NL_HTTP_POST, "/api/v1/system/info", NULL, "{}"); + TEST_ASSERT_EQUAL_INT(405, resp.status); +} + +/* --- device/state --- */ +static void test_get_state(void) +{ + const cJSON *cap; + fake_settings.fade_in_ms = 700; + call(NL_HTTP_GET, "/api/v1/device/state", NULL, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_TRUE(cJSON_IsTrue(field("on"))); + TEST_ASSERT_EQUAL_INT(100, field("brightness")->valueint); + TEST_ASSERT_EQUAL_STRING("white", cJSON_GetStringValue(field("mode"))); + TEST_ASSERT_EQUAL_INT(255, cJSON_GetObjectItem(field("color"), "g")->valueint); + TEST_ASSERT_EQUAL_INT(2700, field("color_temp")->valueint); + 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); + 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); + TEST_ASSERT_EQUAL_INT(6500, cJSON_GetArrayItem(cJSON_GetObjectItem(cap, "color_temp"), 1)->valueint); +} + +static void test_white_board_capabilities(void) +{ + fake_api_reset("e27_cw"); + call(NL_HTTP_GET, "/api/v1/device/state", NULL, NULL); + TEST_ASSERT_TRUE(cJSON_IsFalse(cJSON_GetObjectItem(field("capabilities"), "color"))); +} + +static void test_post_state_applies_change(void) +{ + call(NL_HTTP_POST, "/api/v1/device/state", NULL, "{\"on\":false,\"brightness\":50,\"transition\":800}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_apply_calls); + TEST_ASSERT_TRUE(fake_last_change.has_on); + TEST_ASSERT_FALSE(fake_last_change.on); + TEST_ASSERT_EQUAL_UINT8(50, fake_last_change.brightness); + TEST_ASSERT_TRUE(fake_last_change.has_transition); + TEST_ASSERT_EQUAL_UINT32(800, fake_last_change.transition_ms); + TEST_ASSERT_TRUE(cJSON_IsFalse(field("on"))); + TEST_ASSERT_EQUAL_INT(50, field("brightness")->valueint); + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); +} + +static void test_post_color_and_ct(void) +{ + call(NL_HTTP_POST, "/api/v1/device/state", NULL, "{\"color\":{\"r\":255,\"g\":120,\"b\":0}}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_TRUE(fake_last_change.has_color); + TEST_ASSERT_EQUAL_UINT8(120, fake_last_change.g); + TEST_ASSERT_EQUAL_STRING("color", cJSON_GetStringValue(field("mode"))); + call(NL_HTTP_POST, "/api/v1/device/state", NULL, "{\"color_temp\":4000}"); + TEST_ASSERT_TRUE(fake_last_change.has_ct); + TEST_ASSERT_EQUAL_UINT16(4000, fake_last_change.ct); +} + +static void test_post_validation(void) +{ + static const struct { const char *body; const char *err; } CASES[] = { + {"not json", "invalid json"}, + {"[1,2]", "invalid json"}, + {"{\"on\":1}", "invalid on"}, + {"{\"brightness\":0}", "invalid brightness"}, + {"{\"brightness\":101}", "invalid brightness"}, + {"{\"brightness\":1.5}", "invalid brightness"}, + {"{\"brightness\":\"50\"}", "invalid brightness"}, + {"{\"color\":{\"r\":1,\"g\":2}}", "invalid color"}, + {"{\"color\":{\"r\":1,\"g\":2,\"b\":256}}", "invalid color"}, + {"{\"color_temp\":999}", "invalid color_temp"}, + {"{\"transition\":60001}", "invalid transition"}, + {"{\"power_on_behaviour\":\"maybe\"}", "invalid power_on_behaviour"}, + {"{\"fade_in\":10001}", "invalid fade_in"}, + {"{\"fade_out\":-1}", "invalid fade_out"}, + }; + for (size_t i = 0; i < sizeof CASES / sizeof CASES[0]; i++) { + call(NL_HTTP_POST, "/api/v1/device/state", NULL, CASES[i].body); + TEST_ASSERT_EQUAL_INT_MESSAGE(400, resp.status, CASES[i].body); + TEST_ASSERT_EQUAL_STRING_MESSAGE(CASES[i].err, error_text(), CASES[i].body); + } + call(NL_HTTP_POST, "/api/v1/device/state", NULL, NULL); + TEST_ASSERT_EQUAL_STRING("invalid json", error_text()); + TEST_ASSERT_EQUAL_INT(0, fake_apply_calls); + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); +} + +static void test_post_control_errors(void) +{ + fake_apply_result = NL_CTRL_ERR_NO_RGB; + call(NL_HTTP_POST, "/api/v1/device/state", NULL, "{\"color\":{\"r\":1,\"g\":2,\"b\":3},\"fade_in\":100}"); + TEST_ASSERT_EQUAL_INT(400, resp.status); + TEST_ASSERT_EQUAL_STRING("color not supported", error_text()); + TEST_ASSERT_EQUAL_INT(0, fake_save_calls); /* settings not saved after a failed change */ + fake_apply_result = NL_CTRL_ERR_COLOR_AND_CT; + call(NL_HTTP_POST, "/api/v1/device/state", NULL, "{\"color\":{\"r\":1,\"g\":2,\"b\":3},\"color_temp\":3000}"); + TEST_ASSERT_EQUAL_STRING("color and color_temp are exclusive", error_text()); +} + +static void test_post_settings_fields(void) +{ + call(NL_HTTP_POST, "/api/v1/device/state", NULL, + "{\"power_on_behaviour\":\"on\",\"fade_in\":1000,\"fade_out\":0,\"transition\":300}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(0, fake_apply_calls); /* transition alone is ignored */ + TEST_ASSERT_EQUAL_INT(1, fake_save_calls); + TEST_ASSERT_EQUAL_UINT8(NL_POB_ON, fake_settings.power_on_behaviour); + TEST_ASSERT_EQUAL_UINT16(1000, fake_settings.fade_in_ms); + TEST_ASSERT_EQUAL_UINT16(0, fake_settings.fade_out_ms); + TEST_ASSERT_EQUAL_STRING("on", cJSON_GetStringValue(field("power_on_behaviour"))); +} + +static void test_post_save_failure(void) +{ + fake_save_result = -1; + call(NL_HTTP_POST, "/api/v1/device/state", NULL, "{\"fade_in\":100}"); + TEST_ASSERT_EQUAL_INT(500, resp.status); + TEST_ASSERT_EQUAL_STRING("save failed", error_text()); +} + +static void test_auth_all_protects_device_state(void) +{ + fake_settings.auth_all = 1; + call(NL_HTTP_GET, "/api/v1/device/state", NULL, NULL); + TEST_ASSERT_EQUAL_INT(401, resp.status); + TEST_ASSERT_EQUAL_STRING("unauthorized", error_text()); + call(NL_HTTP_GET, "/api/v1/device/state", TOKEN, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + fake_open = true; + call(NL_HTTP_GET, "/api/v1/device/state", NULL, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); +} + +/* --- system/info --- */ +static void test_info_is_open(void) +{ + const cJSON *w; + call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_STRING("evk", cJSON_GetStringValue(field("device_type"))); + TEST_ASSERT_EQUAL_STRING("W600 EVK v1.2", cJSON_GetStringValue(field("product_name"))); + TEST_ASSERT_EQUAL_STRING("1.2.3", cJSON_GetStringValue(field("fw_version"))); + TEST_ASSERT_EQUAL_STRING("2026-09-11", cJSON_GetStringValue(field("build_date"))); + TEST_ASSERT_EQUAL_STRING("28:6d:cd:5c:68:1f", cJSON_GetStringValue(field("mac"))); + TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name"))); + w = field("wifi"); + TEST_ASSERT_EQUAL_STRING("sta", cJSON_GetStringValue(cJSON_GetObjectItem(w, "mode"))); + TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(w, "ssid"))); + TEST_ASSERT_EQUAL_INT(-58, cJSON_GetObjectItem(w, "rssi")->valueint); + TEST_ASSERT_EQUAL_STRING("192.168.1.50", cJSON_GetStringValue(cJSON_GetObjectItem(w, "ip"))); + TEST_ASSERT_TRUE(cJSON_IsNull(cJSON_GetObjectItem(w, "sta_ip"))); + TEST_ASSERT_TRUE(cJSON_IsNull(cJSON_GetObjectItem(w, "error"))); + TEST_ASSERT_EQUAL_INT(123, field("uptime")->valueint); + TEST_ASSERT_EQUAL_INT(40000, field("free_heap")->valueint); + TEST_ASSERT_EQUAL_STRING("unknown", cJSON_GetStringValue(field("reset_reason"))); + TEST_ASSERT_TRUE(cJSON_IsNull(field("last_ota_error"))); +} + +static void test_info_with_sta_ip_and_error(void) +{ + strcpy(fake_info.sta_ip, "192.168.1.50"); + fake_info.wifi_error = "auth"; + call(NL_HTTP_GET, "/api/v1/system/info", NULL, NULL); + TEST_ASSERT_EQUAL_STRING("192.168.1.50", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "sta_ip"))); + TEST_ASSERT_EQUAL_STRING("auth", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "error"))); +} + +/* --- system/settings --- */ +static void test_settings_auth(void) +{ + call(NL_HTTP_GET, "/api/v1/system/settings", NULL, NULL); + TEST_ASSERT_EQUAL_INT(401, resp.status); + call(NL_HTTP_GET, "/api/v1/system/settings", "aabb", NULL); + TEST_ASSERT_EQUAL_INT(401, resp.status); + call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN "x", NULL); + TEST_ASSERT_EQUAL_INT(401, resp.status); + call(NL_HTTP_GET, "/api/v1/system/settings", TOKEN, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_STRING("Wohnzimmer", cJSON_GetStringValue(field("name"))); + TEST_ASSERT_EQUAL_STRING("ninHome", cJSON_GetStringValue(cJSON_GetObjectItem(field("wifi"), "ssid"))); + TEST_ASSERT_TRUE(cJSON_IsFalse(field("auth_all"))); + TEST_ASSERT_NULL(cJSON_GetObjectItem(field("wifi"), "password")); /* never exposed */ + fake_open = true; + call(NL_HTTP_GET, "/api/v1/system/settings", NULL, NULL); + TEST_ASSERT_EQUAL_INT(200, resp.status); +} + +static void test_settings_wifi(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"ninIOT\",\"password\":\"secret12\"}"); + TEST_ASSERT_EQUAL_INT(202, resp.status); + TEST_ASSERT_EQUAL_STRING("connecting", cJSON_GetStringValue(field("status"))); + TEST_ASSERT_EQUAL_STRING("ninIOT", fake_wifi_ssid); + TEST_ASSERT_EQUAL_STRING("secret12", fake_wifi_pass); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"open\"}"); + TEST_ASSERT_EQUAL_INT(202, resp.status); + TEST_ASSERT_EQUAL_STRING("", fake_wifi_pass); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"wifi\",\"ssid\":\"123456789012345678901234567890123\"}"); + TEST_ASSERT_EQUAL_STRING("invalid ssid", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"x\",\"password\":5}"); + TEST_ASSERT_EQUAL_STRING("invalid password", error_text()); + fake_wifi_result = -1; + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"wifi\",\"ssid\":\"x\"}"); + TEST_ASSERT_EQUAL_INT(503, resp.status); + TEST_ASSERT_EQUAL_STRING("wifi busy", error_text()); +} + +static void test_settings_name_token_auth(void) +{ + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"device_name\",\"name\":\"Küche\"}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_TRUE(cJSON_IsTrue(field("ok"))); + TEST_ASSERT_EQUAL_STRING("Küche", fake_settings.name); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"device_name\",\"name\":\"\"}"); + TEST_ASSERT_EQUAL_STRING("invalid name", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, + "{\"type\":\"device_name\",\"name\":\"123456789012345678901234567890123\"}"); + TEST_ASSERT_EQUAL_STRING("invalid name", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"token\",\"token\":\"1234567\"}"); + TEST_ASSERT_EQUAL_STRING("invalid token", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", TOKEN, "{\"type\":\"token\",\"token\":\"12345678\"}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_STRING("12345678", fake_settings.token); + + call(NL_HTTP_PUT, "/api/v1/system/settings", "12345678", "{\"type\":\"auth\",\"all\":true}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_UINT8(1, fake_settings.auth_all); + call(NL_HTTP_PUT, "/api/v1/system/settings", "12345678", "{\"type\":\"auth\",\"all\":\"yes\"}"); + TEST_ASSERT_EQUAL_STRING("invalid all", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", "12345678", "{\"type\":\"colour\"}"); + TEST_ASSERT_EQUAL_STRING("invalid type", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/settings", "12345678", "{}"); + TEST_ASSERT_EQUAL_STRING("invalid type", error_text()); + fake_save_result = -1; + call(NL_HTTP_PUT, "/api/v1/system/settings", "12345678", "{\"type\":\"device_name\",\"name\":\"x\"}"); + TEST_ASSERT_EQUAL_INT(500, resp.status); +} + +/* --- system/command --- */ +static void test_commands(void) +{ + call(NL_HTTP_GET, "/api/v1/system/command", TOKEN, NULL); + TEST_ASSERT_EQUAL_INT(405, resp.status); + call(NL_HTTP_PUT, "/api/v1/system/command", NULL, "{\"command\":{\"type\":\"ping\"}}"); + TEST_ASSERT_EQUAL_INT(401, resp.status); + + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"ping\"}}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_ping_calls); + TEST_ASSERT_EQUAL(NL_API_AFTER_NONE, resp.after); + + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"reboot\"}}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL(NL_API_AFTER_REBOOT, resp.after); + + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"factory_reset\"}}"); + TEST_ASSERT_EQUAL_INT(200, resp.status); + TEST_ASSERT_EQUAL_INT(1, fake_factory_calls); + + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}"); + TEST_ASSERT_EQUAL_INT(501, resp.status); + TEST_ASSERT_EQUAL_STRING("fw_update not available", error_text()); + TEST_ASSERT_EQUAL_STRING("http://x/y.img", fake_fw_url); + fake_fw_result = 0; + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"http://x/y.img\"}}"); + TEST_ASSERT_EQUAL_INT(202, resp.status); + TEST_ASSERT_EQUAL_STRING("updating", cJSON_GetStringValue(field("status"))); + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"fw_update\",\"url\":\"ftp://x\"}}"); + TEST_ASSERT_EQUAL_STRING("invalid url", error_text()); + + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"command\":{\"type\":\"dance\"}}"); + TEST_ASSERT_EQUAL_STRING("invalid command", error_text()); + call(NL_HTTP_PUT, "/api/v1/system/command", TOKEN, "{\"type\":\"reboot\"}"); + TEST_ASSERT_EQUAL_STRING("invalid command", error_text()); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_unknown_path_and_method); + RUN_TEST(test_get_state); + RUN_TEST(test_white_board_capabilities); + RUN_TEST(test_post_state_applies_change); + RUN_TEST(test_post_color_and_ct); + RUN_TEST(test_post_validation); + RUN_TEST(test_post_control_errors); + RUN_TEST(test_post_settings_fields); + RUN_TEST(test_post_save_failure); + RUN_TEST(test_auth_all_protects_device_state); + RUN_TEST(test_info_is_open); + RUN_TEST(test_info_with_sta_ip_and_error); + RUN_TEST(test_settings_auth); + RUN_TEST(test_settings_wifi); + RUN_TEST(test_settings_name_token_auth); + RUN_TEST(test_commands); + return UNITY_END(); +}