Files
ninlight-w600/src/net/nl_api.c
T
Michael SauerandClaude Sonnet 5 2dfb1fd1c2 feat: GET system/settings zeigt Herkunft der WLAN-Zugangsdaten
Die Antwort gab bisher nur die gespeicherte SSID aus. Wurde eine Lampe nie
per REST onboardet, ist die leer, und die Lampe laeuft in Wirklichkeit auf
den einkompilierten NINLIGHT_WIFI_SSID/-PASSWORD (siehe app_main.c) -
GET system/settings suggerierte damit "kein WLAN konfiguriert", obwohl die
Lampe zuverlaessig verbunden ist, und widersprach scheinbar system/info.

Additiv, ohne die Bedeutung von "ssid" zu aendern: wifi.source ist jetzt
"stored" oder "compiled". Spec (§7.2) und bruno/ninlight.yml dokumentieren
das Feld, ein neuer Hosttest deckt den compiled-Fall ab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 01:29:34 +02:00

413 lines
14 KiB
C

#include "net/nl_api.h"
#include <stdio.h>
#include <string.h>
#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);
double d;
if (!v)
return 0;
if (!cJSON_IsNumber(v))
return -1;
d = v->valuedouble;
/* M5: reject NaN and out-of-range values before casting to (long) - a huge or NaN
* double is undefined behaviour to cast, so lo/hi must be checked first. */
if (d != d || d < lo || d > hi)
return -1;
if (d != (double)(long)d)
return -1;
*out = (long)d;
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);
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);
/* "ssid" alone can't be told apart from "kein WLAN konfiguriert": ein leerer
* gespeicherter Datensatz laesst app_main.c beim Boot auf die einkompilierten
* NINLIGHT_WIFI_SSID/-PASSWORD zurueckfallen (siehe dort), und die Lampe kommt
* trotzdem zuverlaessig ins WLAN. Wer nur "ssid" sieht, kann das nicht
* unterscheiden - "source" macht additiv sichtbar, woher die Zugangsdaten
* kommen, ohne die Bedeutung von "ssid" zu aendern. */
cJSON_AddStringToObject(w, "source", s.wifi_ssid[0] ? "stored" : "compiled");
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 (pr == 1 && strlen(b) != 0 && strlen(b) < 8) {
/* M2: WPA2 passwords must be empty (open network) or 8..64 chars */
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) {
/* fw_update() itself can genuinely fail now (busy, or a rejected URL) - 409 with the
* reason, not a blanket "not implemented". */
/* Do not reuse last_ota_error for this message: it belongs to the running or last
* update, not to the request we are rejecting right now. */
reply_error(resp, 409, "an update is already running");
} 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);
/* M4: a truncated Authorization value must never accidentally match a shorter token. */
authed = ctx->open_access() || (!req->auth_too_long && 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");
}
}