From 3fee19c60a9dd9a28b1f20003b587d64b5253da0 Mon Sep 17 00:00:00 2001 From: Ronny Eia <3652665+eiaro@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:52:54 +0200 Subject: [PATCH] Replace form-urlencoded handlers with a JSON API + CORS Both httpd servers now expose JSON endpoints (POST /api/wifi on the commissioning AP; GET /api/status, GET/POST /api/mqtt on the settings server) instead of form-urlencoded bodies, with CORS enabled (http_cors.h) so a separately-hosted web app can call them from a browser across origins -- not just the on-device pages, which now use the same endpoints via fetch() too. No JSON library ships with ESP-IDF v6.0.2 core (verified: no cJSON, no bundled json component). Given these payloads are tiny, flat, fixed-shape objects, json_helpers.h hand-rolls minimal strstr-based extraction rather than adding a 4th external registry dependency (already have mdns, mqtt, led_strip). The settings page (mqtt.cpp) changes from server-side snprintf-built dynamic HTML to a static embedded settings.html that fetches /api/mqtt on load -- simpler code, and the actual point of the refactor: the same API a future kvida-sdk client can use. url_decode.h is removed (no longer needed once request bodies are JSON instead of form-urlencoded). Verified end-to-end against real hardware: /api/status, /api/mqtt, and the CORS preflight for POST /api/mqtt all return correct responses/headers when queried with a cross-origin Origin header. Co-Authored-By: Claude Sonnet 5 --- components/transport/CMakeLists.txt | 2 +- components/transport/README.md | 14 ++- components/transport/src/commissioning.cpp | 38 +++++-- components/transport/src/http_cors.h | 24 ++++ components/transport/src/json_helpers.h | 71 ++++++++++++ components/transport/src/mqtt.cpp | 125 +++++++++++++-------- components/transport/src/portal.html | 31 ++++- components/transport/src/settings.html | 66 +++++++++++ components/transport/src/url_decode.h | 28 ----- 9 files changed, 306 insertions(+), 93 deletions(-) create mode 100644 components/transport/src/http_cors.h create mode 100644 components/transport/src/json_helpers.h create mode 100644 components/transport/src/settings.html delete mode 100644 components/transport/src/url_decode.h diff --git a/components/transport/CMakeLists.txt b/components/transport/CMakeLists.txt index 648b9ee..24fd9cd 100644 --- a/components/transport/CMakeLists.txt +++ b/components/transport/CMakeLists.txt @@ -3,5 +3,5 @@ idf_component_register( INCLUDE_DIRS "include" PRIV_INCLUDE_DIRS "src" PRIV_REQUIRES esp_wifi esp_netif esp_event esp_http_server esp_timer nvs_flash espressif__mqtt espressif__mdns - EMBED_FILES "src/portal.html" + EMBED_FILES "src/portal.html" "src/settings.html" ) diff --git a/components/transport/README.md b/components/transport/README.md index 23bc1ce..e86512c 100644 --- a/components/transport/README.md +++ b/components/transport/README.md @@ -12,7 +12,7 @@ Future transports (Zigbee, Thread, Matter) should implement the same publish API - On boot, `try_stored_credentials()` attempts to connect using credentials saved in NVS from a previous session. - If none exist (or the caller chooses to), `start_commissioning()` opens a SoftAP (`Kvida-XXXXXX`, derived from the MAC) with a captive portal: a DNS server (`src/dns_server.c`/`.h`, vendored from ESP-IDF's official `captive_portal` example, Unlicense/CC0) answers every query with the AP's own IP, and DHCP option 114 plus a 404-redirects-to-`/` HTTP handler get most phones/laptops to auto-open the setup page (`src/portal.html`) in a plain browser. -- Submitting the form (`POST /connect`) saves the credentials to NVS and switches Wi-Fi to STA mode. The AP is open (no password) by design -- see the comment above `ap_config.ap.authmode` in `commissioning.cpp` for the reasoning and the tradeoff. +- Submitting the form (`POST /api/wifi`, JSON body `{"ssid","password"}`) saves the credentials to NVS and switches Wi-Fi to STA mode. The AP is open (no password) by design -- see the comment above `ap_config.ap.authmode` in `commissioning.cpp` for the reasoning and the tradeoff. - A 5-minute `esp_timer` closes the commissioning window automatically if nobody submits credentials. - **IPv6**: once STA gets its IPv4 address (`IP_EVENT_STA_GOT_IP` -- not `WIFI_EVENT_STA_CONNECTED`, see the comment in `commissioning.cpp` for why that timing matters), `esp_netif_create_ip6_linklocal()` requests a link-local address (`CONFIG_LWIP_IPV6=y` is set explicitly in `sdkconfig.defaults`); `IP_EVENT_GOT_IP6` is logged when it arrives. Verified end-to-end on real ESP32-C6 hardware. - Not yet wired up: re-entering commissioning on an already-configured device needs a physical trigger (the user button) per AGENTS.md's "configuration mode requires physical user interaction" principle -- blocked on `drivers` having a real button implementation. Network scanning (SSID dropdown instead of free text) was left out of this first pass to keep scope tight. @@ -27,4 +27,16 @@ Future transports (Zigbee, Thread, Matter) should implement the same publish API - On `MQTT_EVENT_CONNECTED`: publishes (retained) an HA MQTT Discovery config for a diagnostic `connectivity` binary_sensor, grouped under a `device` block (`identifiers`/`name` = the same `kvida-xxxxxx` id, `manufacturer` "Xylon", `model` "Kvida"), then publishes `online` to the availability topic. MQTT's own LWT (`session.last_will`, set at client init) publishes `offline` to the same topic if the connection drops. Verified end-to-end: device appears in Home Assistant with a "Connected" Connectivity sensor. - No generic semantic `publish(topic, value)` API yet -- deferred until `profiles` has real sensor data to publish; building it now would be speculative. +## JSON API + +Both httpd servers (the commissioning AP's and the settings server's) expose a small JSON API, CORS-enabled (`src/http_cors.h`) so a separately-hosted web app (`kvida-sdk`, e.g. via `npm run dev`) can call them from a browser across origins -- not just the on-device pages, which use the same endpoints via `fetch()`: + +- `POST /api/wifi` (commissioning AP only) -- `{"ssid","password"}`, replaces the old form-urlencoded `/connect`. +- `GET /api/status` (settings server) -- `{"device_id","wifi_connected","mqtt_connected"}`. +- `GET /api/mqtt` / `POST /api/mqtt` (settings server) -- `{"host","port","username"[,"password"]}`; `GET` never returns the password. + +No JSON library ships with ESP-IDF v6.0.2 core (verified: no `cJSON`, no bundled `json` component). Given these payloads are tiny, flat, fixed-shape objects, `src/json_helpers.h` hand-rolls minimal `strstr`-based extraction rather than adding a 4th external registry dependency (already have `mdns`, `mqtt`, `led_strip`) -- explicitly not a general parser (no nesting, arrays, or escaping beyond what these specific request bodies need). + +**Important constraint that shaped this**: the Wi-Fi captive portal must keep being served by the device itself -- a phone connected to the isolated SoftAP has no route to any externally-run app. Only the MQTT/settings step (once the device has real LAN connectivity) can be driven by a separately-run web app. See `kvida-sdk`. + TODO: A/B OTA update-checking belongs here, driven by ESP-IDF's native `esp_ota_ops` against the `ota_0`/`ota_1` partitions in `partitions.csv` — no extra dependency needed either. diff --git a/components/transport/src/commissioning.cpp b/components/transport/src/commissioning.cpp index 86f9f9c..7e18e07 100644 --- a/components/transport/src/commissioning.cpp +++ b/components/transport/src/commissioning.cpp @@ -5,8 +5,9 @@ #include "device_id.h" #include "dns_server.h" +#include "http_cors.h" +#include "json_helpers.h" #include "mqtt.h" -#include "url_decode.h" #include "esp_event.h" #include "esp_http_server.h" @@ -162,8 +163,14 @@ esp_err_t root_get_handler(httpd_req_t *req) return ESP_OK; } -esp_err_t connect_post_handler(httpd_req_t *req) +// POST /api/wifi -- body: {"ssid":"...","password":"..."} (password +// optional, for open networks). Replaces the previous form-urlencoded +// /connect endpoint so kvida-sdk (or any other client on the LAN) can +// drive Wi-Fi setup the same way the on-device portal.html does. +esp_err_t api_wifi_post_handler(httpd_req_t *req) { + add_cors_headers(req); + char body[256]; int len = httpd_req_recv(req, body, sizeof(body) - 1); if (len <= 0) { @@ -174,27 +181,30 @@ esp_err_t connect_post_handler(httpd_req_t *req) char ssid[33] = {0}; char pass[65] = {0}; - bool have_ssid = httpd_query_key_value(body, "ssid", ssid, sizeof(ssid)) == ESP_OK; - httpd_query_key_value(body, "password", pass, sizeof(pass)); // optional: open networks + bool have_ssid = json_get_string(body, "ssid", ssid, sizeof(ssid)); + json_get_string(body, "password", pass, sizeof(pass)); // optional: open networks if (!have_ssid || ssid[0] == '\0') { httpd_resp_set_status(req, "400 Bad Request"); - httpd_resp_send(req, "Missing SSID", HTTPD_RESP_USE_STRLEN); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"error\":\"missing ssid\"}", HTTPD_RESP_USE_STRLEN); return ESP_OK; } - url_decode(ssid); - url_decode(pass); - save_credentials(ssid, pass); - httpd_resp_set_type(req, "text/html"); - httpd_resp_send(req, "

Connecting... you can close this page.

", HTTPD_RESP_USE_STRLEN); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"connecting\"}", HTTPD_RESP_USE_STRLEN); connect_sta(ssid, pass); return ESP_OK; } +esp_err_t api_wifi_options_handler(httpd_req_t *req) +{ + return cors_preflight_handler(req); +} + esp_err_t not_found_handler(httpd_req_t *req, httpd_err_code_t /*err*/) { // Classic captive-portal trick: send every unknown path back to "/". @@ -239,9 +249,13 @@ void start_http_server() } static const httpd_uri_t root_uri = {.uri = "/", .method = HTTP_GET, .handler = root_get_handler, .user_ctx = nullptr}; - static const httpd_uri_t connect_uri = {.uri = "/connect", .method = HTTP_POST, .handler = connect_post_handler, .user_ctx = nullptr}; + static const httpd_uri_t api_wifi_post_uri = { + .uri = "/api/wifi", .method = HTTP_POST, .handler = api_wifi_post_handler, .user_ctx = nullptr}; + static const httpd_uri_t api_wifi_options_uri = { + .uri = "/api/wifi", .method = HTTP_OPTIONS, .handler = api_wifi_options_handler, .user_ctx = nullptr}; httpd_register_uri_handler(g_httpd, &root_uri); - httpd_register_uri_handler(g_httpd, &connect_uri); + httpd_register_uri_handler(g_httpd, &api_wifi_post_uri); + httpd_register_uri_handler(g_httpd, &api_wifi_options_uri); httpd_register_err_handler(g_httpd, HTTPD_404_NOT_FOUND, not_found_handler); } diff --git a/components/transport/src/http_cors.h b/components/transport/src/http_cors.h new file mode 100644 index 0000000..0b3ea22 --- /dev/null +++ b/components/transport/src/http_cors.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esp_http_server.h" + +namespace kvida { + +// Allows a separately-hosted web app (kvida-sdk, e.g. served from +// localhost:5173 via Vite during development) to call the device's JSON +// API from a browser across origins. +inline void add_cors_headers(httpd_req_t *req) +{ + httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + httpd_resp_set_hdr(req, "Access-Control-Allow-Headers", "Content-Type"); +} + +inline esp_err_t cors_preflight_handler(httpd_req_t *req) +{ + add_cors_headers(req); + httpd_resp_send(req, nullptr, 0); + return ESP_OK; +} + +} // namespace kvida diff --git a/components/transport/src/json_helpers.h b/components/transport/src/json_helpers.h new file mode 100644 index 0000000..efe6674 --- /dev/null +++ b/components/transport/src/json_helpers.h @@ -0,0 +1,71 @@ +#pragma once + +#include +#include + +namespace kvida { + +// Minimal extraction helpers for flat, fixed-shape JSON objects like +// {"ssid":"...","password":"..."}. Not a general JSON parser -- no +// nesting, arrays, or escape sequences beyond what's needed for our own +// small request bodies. Values are copied raw (not unescaped); callers +// that need `"` or `\` in a field would need more than this. + +inline bool json_get_string(const char *body, const char *key, char *out, size_t out_cap) +{ + char needle[40]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + + const char *key_pos = strstr(body, needle); + if (!key_pos) { + out[0] = '\0'; + return false; + } + + const char *colon = strchr(key_pos + strlen(needle), ':'); + if (!colon) { + out[0] = '\0'; + return false; + } + + const char *value_start = strchr(colon, '"'); + if (!value_start) { + out[0] = '\0'; + return false; + } + value_start++; + + const char *value_end = strchr(value_start, '"'); + if (!value_end) { + out[0] = '\0'; + return false; + } + + size_t len = static_cast(value_end - value_start); + if (len > out_cap - 1) { + len = out_cap - 1; + } + memcpy(out, value_start, len); + out[len] = '\0'; + return true; +} + +inline int json_get_int(const char *body, const char *key, int default_value) +{ + char needle[40]; + snprintf(needle, sizeof(needle), "\"%s\"", key); + + const char *key_pos = strstr(body, needle); + if (!key_pos) { + return default_value; + } + + const char *colon = strchr(key_pos + strlen(needle), ':'); + if (!colon) { + return default_value; + } + + return atoi(colon + 1); +} + +} // namespace kvida diff --git a/components/transport/src/mqtt.cpp b/components/transport/src/mqtt.cpp index 6adbe27..7790f65 100644 --- a/components/transport/src/mqtt.cpp +++ b/components/transport/src/mqtt.cpp @@ -3,11 +3,12 @@ #include #include #include -#include #include +#include "commissioning.h" #include "device_id.h" -#include "url_decode.h" +#include "http_cors.h" +#include "json_helpers.h" #include "esp_http_server.h" #include "esp_log.h" @@ -29,6 +30,9 @@ constexpr const char *kNvsKeyPort = "port"; constexpr const char *kNvsKeyUser = "user"; constexpr const char *kNvsKeyPass = "pass"; +extern const uint8_t settings_html_start[] asm("_binary_settings_html_start"); +extern const uint8_t settings_html_end[] asm("_binary_settings_html_end"); + std::atomic g_connected{false}; esp_mqtt_client_handle_t g_client = nullptr; esp_timer_handle_t g_retry_timer = nullptr; @@ -352,8 +356,39 @@ void try_discover_and_connect() start_client(g_broker_uri); } -esp_err_t settings_get_handler(httpd_req_t *req) +esp_err_t settings_root_get_handler(httpd_req_t *req) { + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, reinterpret_cast(settings_html_start), + settings_html_end - settings_html_start); + return ESP_OK; +} + +// GET /api/status -- device/connectivity info, usable by the on-device +// pages and kvida-sdk alike. +esp_err_t api_status_get_handler(httpd_req_t *req) +{ + add_cors_headers(req); + + char id[16]; + device_id(id, sizeof(id)); + + char payload[160]; + snprintf(payload, sizeof(payload), "{\"device_id\":\"%s\",\"wifi_connected\":%s,\"mqtt_connected\":%s}", id, + commissioning_state() == CommissioningState::Connected ? "true" : "false", + g_connected.load() ? "true" : "false"); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, payload, HTTPD_RESP_USE_STRLEN); + return ESP_OK; +} + +// GET /api/mqtt -- current broker host/port/username. Password is never +// returned. +esp_err_t api_mqtt_get_handler(httpd_req_t *req) +{ + add_cors_headers(req); + char host[64] = {0}; uint16_t port = 1883; load_manual_broker(host, sizeof(host), &port); @@ -361,38 +396,23 @@ esp_err_t settings_get_handler(httpd_req_t *req) char user[33] = {0}; char pass_unused[65]; load_mqtt_credentials(user, sizeof(user), pass_unused, sizeof(pass_unused)); - // Password is intentionally never echoed back into the page. - char html[1024]; - snprintf(html, sizeof(html), - "" - "" - "Kvida settings" - "" - "" - "

MQTT settings

" - "
" - "" - "" - "" - "" - "" - "
", - host, port, user); + char payload[256]; + snprintf(payload, sizeof(payload), "{\"host\":\"%s\",\"port\":%u,\"username\":\"%s\"}", host, port, user); - httpd_resp_set_type(req, "text/html"); - httpd_resp_send(req, html, HTTPD_RESP_USE_STRLEN); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, payload, HTTPD_RESP_USE_STRLEN); return ESP_OK; } -esp_err_t settings_post_handler(httpd_req_t *req) +// POST /api/mqtt -- body: {"host":"...","port":1883,"username":"...","password":"..."}. +// Blank password means "keep the current one" (GET /api/mqtt never +// returns it, so blank isn't a deliberate choice to go anonymous the way +// it is during initial setup). +esp_err_t api_mqtt_post_handler(httpd_req_t *req) { + add_cors_headers(req); + char body[256]; int len = httpd_req_recv(req, body, sizeof(body) - 1); if (len <= 0) { @@ -402,44 +422,41 @@ esp_err_t settings_post_handler(httpd_req_t *req) body[len] = '\0'; char host[64] = {0}; - char port_str[8] = {0}; char user[33] = {0}; char pass[65] = {0}; - bool have_host = httpd_query_key_value(body, "host", host, sizeof(host)) == ESP_OK; - httpd_query_key_value(body, "port", port_str, sizeof(port_str)); - httpd_query_key_value(body, "username", user, sizeof(user)); // optional: anonymous broker access - httpd_query_key_value(body, "password", pass, sizeof(pass)); - - url_decode(host); - url_decode(user); - url_decode(pass); + bool have_host = json_get_string(body, "host", host, sizeof(host)); + json_get_string(body, "username", user, sizeof(user)); // optional: anonymous broker access + json_get_string(body, "password", pass, sizeof(pass)); + uint16_t port = static_cast(json_get_int(body, "port", 1883)); if (!have_host || host[0] == '\0') { httpd_resp_set_status(req, "400 Bad Request"); - httpd_resp_send(req, "Missing broker host", HTTPD_RESP_USE_STRLEN); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"error\":\"missing host\"}", HTTPD_RESP_USE_STRLEN); return ESP_OK; } - uint16_t port = port_str[0] != '\0' ? static_cast(atoi(port_str)) : 1883; save_manual_broker(host, port); - // Blank password field means "keep the current one" (it's never - // echoed back into the settings page, so blank isn't a deliberate - // choice to go anonymous the way it is during initial setup). if (pass[0] == '\0') { char existing_user[33]; load_mqtt_credentials(existing_user, sizeof(existing_user), pass, sizeof(pass)); } save_mqtt_credentials(user, pass); - httpd_resp_set_type(req, "text/html"); - httpd_resp_send(req, "

Saved. Reconnecting to MQTT...

", HTTPD_RESP_USE_STRLEN); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, "{\"status\":\"reconnecting\"}", HTTPD_RESP_USE_STRLEN); snprintf(g_broker_uri, sizeof(g_broker_uri), "mqtt://%s:%u", host, port); start_client(g_broker_uri); return ESP_OK; } +esp_err_t api_options_handler(httpd_req_t *req) +{ + return cors_preflight_handler(req); +} + void start_settings_server() { if (g_settings_httpd) { @@ -470,15 +487,27 @@ void start_settings_server() httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.lru_purge_enable = true; + config.max_uri_handlers = 12; if (httpd_start(&g_settings_httpd, &config) != ESP_OK) { ESP_LOGE(TAG, "Failed to start settings HTTP server"); return; } - static const httpd_uri_t root_uri = {.uri = "/", .method = HTTP_GET, .handler = settings_get_handler, .user_ctx = nullptr}; - static const httpd_uri_t mqtt_uri = {.uri = "/mqtt", .method = HTTP_POST, .handler = settings_post_handler, .user_ctx = nullptr}; + static const httpd_uri_t root_uri = { + .uri = "/", .method = HTTP_GET, .handler = settings_root_get_handler, .user_ctx = nullptr}; + static const httpd_uri_t status_uri = { + .uri = "/api/status", .method = HTTP_GET, .handler = api_status_get_handler, .user_ctx = nullptr}; + static const httpd_uri_t mqtt_get_uri = { + .uri = "/api/mqtt", .method = HTTP_GET, .handler = api_mqtt_get_handler, .user_ctx = nullptr}; + static const httpd_uri_t mqtt_post_uri = { + .uri = "/api/mqtt", .method = HTTP_POST, .handler = api_mqtt_post_handler, .user_ctx = nullptr}; + static const httpd_uri_t mqtt_options_uri = { + .uri = "/api/mqtt", .method = HTTP_OPTIONS, .handler = api_options_handler, .user_ctx = nullptr}; httpd_register_uri_handler(g_settings_httpd, &root_uri); - httpd_register_uri_handler(g_settings_httpd, &mqtt_uri); + httpd_register_uri_handler(g_settings_httpd, &status_uri); + httpd_register_uri_handler(g_settings_httpd, &mqtt_get_uri); + httpd_register_uri_handler(g_settings_httpd, &mqtt_post_uri); + httpd_register_uri_handler(g_settings_httpd, &mqtt_options_uri); } } // namespace diff --git a/components/transport/src/portal.html b/components/transport/src/portal.html index 7fc34de..4b4a1c7 100644 --- a/components/transport/src/portal.html +++ b/components/transport/src/portal.html @@ -8,18 +8,43 @@ label { display: block; margin-top: 1em; } input { width: 100%; box-sizing: border-box; padding: 0.5em; } button { margin-top: 1.5em; width: 100%; padding: 0.7em; } + #status { margin-top: 1em; }

Connect your Kvida device

-
+
+

+ diff --git a/components/transport/src/settings.html b/components/transport/src/settings.html new file mode 100644 index 0000000..cf5df33 --- /dev/null +++ b/components/transport/src/settings.html @@ -0,0 +1,66 @@ + + + + + Kvida settings + + + +

MQTT settings

+
+ + + + + +
+

+ + + diff --git a/components/transport/src/url_decode.h b/components/transport/src/url_decode.h deleted file mode 100644 index 0eb9fb9..0000000 --- a/components/transport/src/url_decode.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include - -namespace kvida { - -// Minimal '%XX' + '+' decoder for URL-encoded form fields (in place). -// Shared by commissioning.cpp's Wi-Fi setup form and mqtt.cpp's MQTT -// settings form. -inline void url_decode(char *s) -{ - char *out = s; - while (*s) { - if (*s == '+') { - *out++ = ' '; - s++; - } else if (*s == '%' && s[1] && s[2]) { - const char hex[3] = {s[1], s[2], 0}; - *out++ = static_cast(strtol(hex, nullptr, 16)); - s += 3; - } else { - *out++ = *s++; - } - } - *out = '\0'; -} - -} // namespace kvida