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:
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
components/transport/src/http_cors.h
Normal file
24
components/transport/src/http_cors.h
Normal 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
|
||||||
71
components/transport/src/json_helpers.h
Normal file
71
components/transport/src/json_helpers.h
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
66
components/transport/src/settings.html
Normal file
66
components/transport/src/settings.html
Normal 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>
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user