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 <noreply@anthropic.com>
This commit is contained in:
Ronny Eia
2026-07-11 14:52:54 +02:00
parent 4af0f1b248
commit 3fee19c60a
9 changed files with 306 additions and 93 deletions

View File

@@ -3,5 +3,5 @@ idf_component_register(
INCLUDE_DIRS "include" INCLUDE_DIRS "include"
PRIV_INCLUDE_DIRS "src" PRIV_INCLUDE_DIRS "src"
PRIV_REQUIRES esp_wifi esp_netif esp_event esp_http_server esp_timer nvs_flash espressif__mqtt espressif__mdns 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"
) )

View File

@@ -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. - 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. - 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. - 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. - **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. - 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. - 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. - 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. 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.

View File

@@ -5,8 +5,9 @@
#include "device_id.h" #include "device_id.h"
#include "dns_server.h" #include "dns_server.h"
#include "http_cors.h"
#include "json_helpers.h"
#include "mqtt.h" #include "mqtt.h"
#include "url_decode.h"
#include "esp_event.h" #include "esp_event.h"
#include "esp_http_server.h" #include "esp_http_server.h"
@@ -162,8 +163,14 @@ esp_err_t root_get_handler(httpd_req_t *req)
return ESP_OK; 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]; char body[256];
int len = httpd_req_recv(req, body, sizeof(body) - 1); int len = httpd_req_recv(req, body, sizeof(body) - 1);
if (len <= 0) { if (len <= 0) {
@@ -174,27 +181,30 @@ esp_err_t connect_post_handler(httpd_req_t *req)
char ssid[33] = {0}; char ssid[33] = {0};
char pass[65] = {0}; char pass[65] = {0};
bool have_ssid = httpd_query_key_value(body, "ssid", ssid, sizeof(ssid)) == ESP_OK; bool have_ssid = json_get_string(body, "ssid", ssid, sizeof(ssid));
httpd_query_key_value(body, "password", pass, sizeof(pass)); // optional: open networks json_get_string(body, "password", pass, sizeof(pass)); // optional: open networks
if (!have_ssid || ssid[0] == '\0') { if (!have_ssid || ssid[0] == '\0') {
httpd_resp_set_status(req, "400 Bad Request"); 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; return ESP_OK;
} }
url_decode(ssid);
url_decode(pass);
save_credentials(ssid, pass); save_credentials(ssid, pass);
httpd_resp_set_type(req, "text/html"); httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "<p>Connecting... you can close this page.</p>", HTTPD_RESP_USE_STRLEN); httpd_resp_send(req, "{\"status\":\"connecting\"}", HTTPD_RESP_USE_STRLEN);
connect_sta(ssid, pass); connect_sta(ssid, pass);
return ESP_OK; 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*/) 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 "/". // 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 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, &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); httpd_register_err_handler(g_httpd, HTTPD_404_NOT_FOUND, not_found_handler);
} }

View File

@@ -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

View File

@@ -0,0 +1,71 @@
#pragma once
#include <cstdlib>
#include <cstring>
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<size_t>(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

View File

@@ -3,11 +3,12 @@
#include <atomic> #include <atomic>
#include <cctype> #include <cctype>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <cstring> #include <cstring>
#include "commissioning.h"
#include "device_id.h" #include "device_id.h"
#include "url_decode.h" #include "http_cors.h"
#include "json_helpers.h"
#include "esp_http_server.h" #include "esp_http_server.h"
#include "esp_log.h" #include "esp_log.h"
@@ -29,6 +30,9 @@ constexpr const char *kNvsKeyPort = "port";
constexpr const char *kNvsKeyUser = "user"; constexpr const char *kNvsKeyUser = "user";
constexpr const char *kNvsKeyPass = "pass"; 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<bool> g_connected{false}; std::atomic<bool> g_connected{false};
esp_mqtt_client_handle_t g_client = nullptr; esp_mqtt_client_handle_t g_client = nullptr;
esp_timer_handle_t g_retry_timer = nullptr; esp_timer_handle_t g_retry_timer = nullptr;
@@ -352,8 +356,39 @@ void try_discover_and_connect()
start_client(g_broker_uri); 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<const char *>(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}; char host[64] = {0};
uint16_t port = 1883; uint16_t port = 1883;
load_manual_broker(host, sizeof(host), &port); 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 user[33] = {0};
char pass_unused[65]; char pass_unused[65];
load_mqtt_credentials(user, sizeof(user), pass_unused, sizeof(pass_unused)); load_mqtt_credentials(user, sizeof(user), pass_unused, sizeof(pass_unused));
// Password is intentionally never echoed back into the page.
char html[1024]; char payload[256];
snprintf(html, sizeof(html), snprintf(payload, sizeof(payload), "{\"host\":\"%s\",\"port\":%u,\"username\":\"%s\"}", host, port, user);
"<!DOCTYPE html><html><head>"
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
"<title>Kvida settings</title>"
"<style>body{font-family:sans-serif;max-width:320px;margin:2em auto;padding:0 1em}"
"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}</style>"
"</head><body>"
"<h1>MQTT settings</h1>"
"<form method=\"POST\" action=\"/mqtt\">"
"<label>MQTT broker host or IP"
"<input type=\"text\" name=\"host\" value=\"%s\" maxlength=\"63\" required autofocus></label>"
"<label>Port"
"<input type=\"number\" name=\"port\" value=\"%u\" maxlength=\"5\"></label>"
"<label>Username (leave blank for anonymous)"
"<input type=\"text\" name=\"username\" value=\"%s\" maxlength=\"32\"></label>"
"<label>Password (leave blank to keep the current one)"
"<input type=\"password\" name=\"password\" maxlength=\"64\"></label>"
"<button type=\"submit\">Save</button>"
"</form></body></html>",
host, port, user);
httpd_resp_set_type(req, "text/html"); httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, html, HTTPD_RESP_USE_STRLEN); httpd_resp_send(req, payload, HTTPD_RESP_USE_STRLEN);
return ESP_OK; 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]; char body[256];
int len = httpd_req_recv(req, body, sizeof(body) - 1); int len = httpd_req_recv(req, body, sizeof(body) - 1);
if (len <= 0) { if (len <= 0) {
@@ -402,44 +422,41 @@ esp_err_t settings_post_handler(httpd_req_t *req)
body[len] = '\0'; body[len] = '\0';
char host[64] = {0}; char host[64] = {0};
char port_str[8] = {0};
char user[33] = {0}; char user[33] = {0};
char pass[65] = {0}; char pass[65] = {0};
bool have_host = httpd_query_key_value(body, "host", host, sizeof(host)) == ESP_OK; bool have_host = json_get_string(body, "host", host, sizeof(host));
httpd_query_key_value(body, "port", port_str, sizeof(port_str)); json_get_string(body, "username", user, sizeof(user)); // optional: anonymous broker access
httpd_query_key_value(body, "username", user, sizeof(user)); // optional: anonymous broker access json_get_string(body, "password", pass, sizeof(pass));
httpd_query_key_value(body, "password", pass, sizeof(pass)); uint16_t port = static_cast<uint16_t>(json_get_int(body, "port", 1883));
url_decode(host);
url_decode(user);
url_decode(pass);
if (!have_host || host[0] == '\0') { if (!have_host || host[0] == '\0') {
httpd_resp_set_status(req, "400 Bad Request"); 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; return ESP_OK;
} }
uint16_t port = port_str[0] != '\0' ? static_cast<uint16_t>(atoi(port_str)) : 1883;
save_manual_broker(host, port); 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') { if (pass[0] == '\0') {
char existing_user[33]; char existing_user[33];
load_mqtt_credentials(existing_user, sizeof(existing_user), pass, sizeof(pass)); load_mqtt_credentials(existing_user, sizeof(existing_user), pass, sizeof(pass));
} }
save_mqtt_credentials(user, pass); save_mqtt_credentials(user, pass);
httpd_resp_set_type(req, "text/html"); httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, "<p>Saved. Reconnecting to MQTT...</p>", HTTPD_RESP_USE_STRLEN); httpd_resp_send(req, "{\"status\":\"reconnecting\"}", HTTPD_RESP_USE_STRLEN);
snprintf(g_broker_uri, sizeof(g_broker_uri), "mqtt://%s:%u", host, port); snprintf(g_broker_uri, sizeof(g_broker_uri), "mqtt://%s:%u", host, port);
start_client(g_broker_uri); start_client(g_broker_uri);
return ESP_OK; return ESP_OK;
} }
esp_err_t api_options_handler(httpd_req_t *req)
{
return cors_preflight_handler(req);
}
void start_settings_server() void start_settings_server()
{ {
if (g_settings_httpd) { if (g_settings_httpd) {
@@ -470,15 +487,27 @@ void start_settings_server()
httpd_config_t config = HTTPD_DEFAULT_CONFIG(); httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true; config.lru_purge_enable = true;
config.max_uri_handlers = 12;
if (httpd_start(&g_settings_httpd, &config) != ESP_OK) { if (httpd_start(&g_settings_httpd, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start settings HTTP server"); ESP_LOGE(TAG, "Failed to start settings HTTP server");
return; return;
} }
static const httpd_uri_t root_uri = {.uri = "/", .method = HTTP_GET, .handler = settings_get_handler, .user_ctx = nullptr}; static const httpd_uri_t root_uri = {
static const httpd_uri_t mqtt_uri = {.uri = "/mqtt", .method = HTTP_POST, .handler = settings_post_handler, .user_ctx = nullptr}; .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, &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 } // namespace

View File

@@ -8,18 +8,43 @@
label { display: block; margin-top: 1em; } label { display: block; margin-top: 1em; }
input { width: 100%; box-sizing: border-box; padding: 0.5em; } input { width: 100%; box-sizing: border-box; padding: 0.5em; }
button { margin-top: 1.5em; width: 100%; padding: 0.7em; } button { margin-top: 1.5em; width: 100%; padding: 0.7em; }
#status { margin-top: 1em; }
</style> </style>
</head> </head>
<body> <body>
<h1>Connect your Kvida device</h1> <h1>Connect your Kvida device</h1>
<form method="POST" action="/connect"> <form id="form">
<label>Wi-Fi network name (SSID) <label>Wi-Fi network name (SSID)
<input type="text" name="ssid" required maxlength="32" autofocus> <input type="text" id="ssid" required maxlength="32" autofocus>
</label> </label>
<label>Wi-Fi password <label>Wi-Fi password
<input type="password" name="password" maxlength="64"> <input type="password" id="password" maxlength="64">
</label> </label>
<button type="submit">Connect</button> <button type="submit">Connect</button>
</form> </form>
<p id="status"></p>
<script>
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('status');
status.textContent = 'Connecting...';
try {
const res = await fetch('/api/wifi', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ssid: document.getElementById('ssid').value,
password: document.getElementById('password').value,
}),
});
const data = await res.json();
status.textContent = res.ok
? 'Connecting... you can close this page.'
: 'Error: ' + (data.error || res.status);
} catch (err) {
status.textContent = 'Request failed: ' + err;
}
});
</script>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Kvida settings</title>
<style>
body { font-family: sans-serif; max-width: 320px; margin: 2em auto; padding: 0 1em; }
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; }
</style>
</head>
<body>
<h1>MQTT settings</h1>
<form id="form">
<label>MQTT broker host or IP
<input type="text" id="host" required maxlength="63" autofocus>
</label>
<label>Port
<input type="number" id="port" maxlength="5">
</label>
<label>Username (leave blank for anonymous)
<input type="text" id="username" maxlength="32">
</label>
<label>Password (leave blank to keep the current one)
<input type="password" id="password" maxlength="64">
</label>
<button type="submit">Save</button>
</form>
<p id="status"></p>
<script>
async function load() {
const res = await fetch('/api/mqtt');
const data = await res.json();
document.getElementById('host').value = data.host || '';
document.getElementById('port').value = data.port || 1883;
document.getElementById('username').value = data.username || '';
}
document.getElementById('form').addEventListener('submit', async (e) => {
e.preventDefault();
const status = document.getElementById('status');
status.textContent = 'Saving...';
try {
const res = await fetch('/api/mqtt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: document.getElementById('host').value,
port: parseInt(document.getElementById('port').value, 10) || 1883,
username: document.getElementById('username').value,
password: document.getElementById('password').value,
}),
});
const data = await res.json();
status.textContent = res.ok ? 'Saved. Reconnecting to MQTT...' : 'Error: ' + (data.error || res.status);
} catch (err) {
status.textContent = 'Request failed: ' + err;
}
});
load();
</script>
</body>
</html>

View File

@@ -1,28 +0,0 @@
#pragma once
#include <cstdlib>
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<char>(strtol(hex, nullptr, 16));
s += 3;
} else {
*out++ = *s++;
}
}
*out = '\0';
}
} // namespace kvida