Add MQTT client + Home Assistant Discovery, verified end-to-end
Adds espressif/mqtt and mdns as the project's first external managed component dependencies (neither ships with ESP-IDF core in v6.0.2 anymore). Once Wi-Fi connects, the device advertises itself on the LAN as kvida-xxxxxx.local and serves a persistent settings page there for MQTT broker host/port and credentials -- the ongoing config channel beyond the one-time Wi-Fi captive portal. mDNS auto-discovery of _mqtt._tcp was the original plan, but verified against a real Home Assistant OS + Mosquitto add-on that it doesn't reliably answer PTR queries on the LAN (confirmed with a raw mDNS query from the host, parsed properly with dnspython after an earlier naive byte-check gave a false positive). Manual host/port entry is now the primary path; mDNS discovery still runs as a secondary fallback attempt, retried every 30s, in case it works on other networks. On MQTT_EVENT_CONNECTED, publishes retained HA MQTT Discovery for a diagnostic "connectivity" binary_sensor (grouped under a Kvida/Xylon device block) plus an online/offline availability topic driven by MQTT's own LWT. Verified end-to-end on real hardware: the device appears in Home Assistant with a "Connected" Connectivity sensor. Also extracts url_decode() (previously local to commissioning.cpp) and a new device_id() helper into shared headers, since both commissioning's Wi-Fi form and the new MQTT settings form need them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
idf_component_register(
|
||||
SRCS "src/commissioning.cpp" "src/dns_server.c"
|
||||
SRCS "src/commissioning.cpp" "src/dns_server.c" "src/mqtt.cpp"
|
||||
INCLUDE_DIRS "include"
|
||||
PRIV_INCLUDE_DIRS "src"
|
||||
PRIV_REQUIRES esp_wifi esp_netif esp_event esp_http_server esp_timer nvs_flash
|
||||
PRIV_REQUIRES esp_wifi esp_netif esp_event esp_http_server esp_timer nvs_flash espressif__mqtt espressif__mdns
|
||||
EMBED_FILES "src/portal.html"
|
||||
)
|
||||
|
||||
@@ -17,6 +17,14 @@ Future transports (Zigbee, Thread, Matter) should implement the same publish API
|
||||
- **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.
|
||||
|
||||
TODO: built on ESP-IDF's own `esp_wifi` and `mqtt_client` (`esp-mqtt`) components once real code lands — both ship with ESP-IDF, no extra registry dependency needed.
|
||||
## MQTT + Home Assistant Discovery
|
||||
|
||||
`include/mqtt.h` / `src/mqtt.cpp`. Neither `esp_mqtt_client` (`mqtt_client.h`) nor `mdns.h` ship with ESP-IDF core in this version (both were moved to the IDF Component Registry) -- pulled in via `idf_component.yml` (`espressif/mqtt`, `mdns`), the project's first external managed dependencies.
|
||||
|
||||
- Once Wi-Fi STA has an IPv4 address, `start_mqtt()` advertises the device on the LAN as `kvida-xxxxxx.local` (mDNS hostname, matching the commissioning AP's SSID) and starts a persistent settings HTTP server there -- the ongoing configuration channel for anything beyond the one-time Wi-Fi bootstrap (MQTT broker/credentials now, more later).
|
||||
- **Broker address**: the settings page has a required host/port field, used directly and given priority over mDNS auto-discovery. mDNS discovery of `_mqtt._tcp` (`mdns_query_ptr`) is attempted as a fallback only if no manual host is saved, retried every 30s -- **verified against a real Home Assistant + Mosquitto add-on setup that this mDNS discovery does not reliably find the broker** (confirmed with a from-scratch raw mDNS query test from the host machine: no PTR answer for `_mqtt._tcp.local` was ever received on that network), hence the manual field being the primary path rather than a rarely-needed fallback.
|
||||
- **Credentials**: also collected on the same settings page (blank username = anonymous; blank password on a resubmit means "keep the current one", since the password is never echoed back into the page for basic hygiene). Real brokers, including Home Assistant's Mosquitto add-on, require auth -- confirmed on real hardware.
|
||||
- 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.
|
||||
|
||||
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.
|
||||
|
||||
8
components/transport/idf_component.yml
Normal file
8
components/transport/idf_component.yml
Normal file
@@ -0,0 +1,8 @@
|
||||
dependencies:
|
||||
mdns:
|
||||
version: "*"
|
||||
# Discovered while implementing this that esp-mqtt is no longer bundled
|
||||
# in ESP-IDF core (moved to the component registry too) -- the plan
|
||||
# assumed it shipped with ESP-IDF, corrected here.
|
||||
espressif/mqtt:
|
||||
version: "*"
|
||||
15
components/transport/include/mqtt.h
Normal file
15
components/transport/include/mqtt.h
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
namespace kvida {
|
||||
|
||||
// Starts mDNS-based MQTT broker discovery (_mqtt._tcp) and, once found,
|
||||
// connects the MQTT client and publishes Home Assistant MQTT Discovery
|
||||
// for a diagnostic "connectivity" entity. Safe to call multiple times
|
||||
// (no-op if already connecting/connected). No public-facing semantic
|
||||
// publish(topic, value) API yet -- deferred until `profiles` has real
|
||||
// sensor data to publish (see components/transport/README.md).
|
||||
void start_mqtt();
|
||||
|
||||
bool mqtt_connected();
|
||||
|
||||
} // namespace kvida
|
||||
@@ -1,10 +1,12 @@
|
||||
#include "commissioning.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "device_id.h"
|
||||
#include "dns_server.h"
|
||||
#include "mqtt.h"
|
||||
#include "url_decode.h"
|
||||
|
||||
#include "esp_event.h"
|
||||
#include "esp_http_server.h"
|
||||
@@ -92,25 +94,6 @@ void save_credentials(const char *ssid, const char *pass)
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
// Minimal '%XX' + '+' decoder for URL-encoded form fields (in place).
|
||||
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';
|
||||
}
|
||||
|
||||
void ensure_event_handlers_registered();
|
||||
|
||||
void wifi_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_id, void * /*data*/)
|
||||
@@ -144,6 +127,8 @@ void ip_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_i
|
||||
ESP_LOGW(TAG, "esp_netif_create_ip6_linklocal -> %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
|
||||
start_mqtt();
|
||||
} else if (event_id == IP_EVENT_GOT_IP6) {
|
||||
auto *event = static_cast<ip_event_got_ip6_t *>(event_data);
|
||||
ESP_LOGI(TAG, "Got IPv6 address: " IPV6STR, IPV62STR(event->ip6_info.ip));
|
||||
@@ -321,12 +306,11 @@ void start_commissioning()
|
||||
}
|
||||
ensure_wifi_driver_initialized();
|
||||
|
||||
uint8_t mac[6];
|
||||
esp_wifi_get_mac(WIFI_IF_AP, mac);
|
||||
char id[16];
|
||||
device_id(id, sizeof(id));
|
||||
|
||||
wifi_config_t ap_config = {};
|
||||
int len = snprintf(reinterpret_cast<char *>(ap_config.ap.ssid), sizeof(ap_config.ap.ssid),
|
||||
"Kvida-%02X%02X%02X", mac[3], mac[4], mac[5]);
|
||||
int len = snprintf(reinterpret_cast<char *>(ap_config.ap.ssid), sizeof(ap_config.ap.ssid), "%s", id);
|
||||
ap_config.ap.ssid_len = static_cast<uint8_t>(len);
|
||||
ap_config.ap.max_connection = 4;
|
||||
// Open AP by design: this is a short-lived, physically-local-only setup
|
||||
|
||||
21
components/transport/src/device_id.h
Normal file
21
components/transport/src/device_id.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
#include "esp_wifi.h"
|
||||
|
||||
namespace kvida {
|
||||
|
||||
// Short, stable device identifier derived from the AP-mode MAC address --
|
||||
// efuse-derived MAC addresses are readable regardless of which Wi-Fi mode
|
||||
// is currently active, so this stays consistent even after switching from
|
||||
// AP to STA. Used as both the commissioning SoftAP SSID and the MQTT/HA
|
||||
// node_id, so users can recognize "their" device across both.
|
||||
inline void device_id(char *out, size_t out_len)
|
||||
{
|
||||
uint8_t mac[6];
|
||||
esp_wifi_get_mac(WIFI_IF_AP, mac);
|
||||
snprintf(out, out_len, "Kvida-%02X%02X%02X", mac[3], mac[4], mac[5]);
|
||||
}
|
||||
|
||||
} // namespace kvida
|
||||
414
components/transport/src/mqtt.cpp
Normal file
414
components/transport/src/mqtt.cpp
Normal file
@@ -0,0 +1,414 @@
|
||||
#include "mqtt.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "device_id.h"
|
||||
#include "url_decode.h"
|
||||
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_netif_ip_addr.h"
|
||||
#include "esp_timer.h"
|
||||
#include "mdns.h"
|
||||
#include "mqtt_client.h"
|
||||
#include "nvs.h"
|
||||
|
||||
namespace kvida {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr const char *TAG = "mqtt";
|
||||
constexpr int64_t kRediscoverRetryUs = 30LL * 1000000; // 30 seconds
|
||||
constexpr const char *kNvsNamespace = "mqtt_cfg";
|
||||
constexpr const char *kNvsKeyHost = "host";
|
||||
constexpr const char *kNvsKeyPort = "port";
|
||||
constexpr const char *kNvsKeyUser = "user";
|
||||
constexpr const char *kNvsKeyPass = "pass";
|
||||
|
||||
std::atomic<bool> g_connected{false};
|
||||
esp_mqtt_client_handle_t g_client = nullptr;
|
||||
esp_timer_handle_t g_retry_timer = nullptr;
|
||||
httpd_handle_t g_settings_httpd = nullptr;
|
||||
|
||||
char g_availability_topic[48];
|
||||
char g_discovery_topic[80];
|
||||
char g_discovery_payload[512];
|
||||
// Remembered so a settings update can reconnect without re-running mDNS
|
||||
// discovery.
|
||||
char g_broker_uri[96]; // "mqtt://" + up to 63-char host + ":" + port + NUL
|
||||
|
||||
void try_discover_and_connect();
|
||||
|
||||
bool load_manual_broker(char *host, size_t host_cap, uint16_t *port)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(kNvsNamespace, NVS_READONLY, &handle) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
size_t host_len = host_cap;
|
||||
esp_err_t host_err = nvs_get_str(handle, kNvsKeyHost, host, &host_len);
|
||||
uint16_t stored_port = 1883;
|
||||
nvs_get_u16(handle, kNvsKeyPort, &stored_port); // optional; default 1883 if unset
|
||||
nvs_close(handle);
|
||||
if (host_err == ESP_OK && host[0] != '\0') {
|
||||
*port = stored_port;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void save_manual_broker(const char *host, uint16_t port)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
ESP_ERROR_CHECK(nvs_open(kNvsNamespace, NVS_READWRITE, &handle));
|
||||
ESP_ERROR_CHECK(nvs_set_str(handle, kNvsKeyHost, host));
|
||||
ESP_ERROR_CHECK(nvs_set_u16(handle, kNvsKeyPort, port));
|
||||
ESP_ERROR_CHECK(nvs_commit(handle));
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
bool load_mqtt_credentials(char *user, size_t user_cap, char *pass, size_t pass_cap)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
if (nvs_open(kNvsNamespace, NVS_READONLY, &handle) != ESP_OK) {
|
||||
return false;
|
||||
}
|
||||
size_t user_len = user_cap;
|
||||
size_t pass_len = pass_cap;
|
||||
esp_err_t user_err = nvs_get_str(handle, kNvsKeyUser, user, &user_len);
|
||||
esp_err_t pass_err = nvs_get_str(handle, kNvsKeyPass, pass, &pass_len);
|
||||
nvs_close(handle);
|
||||
return user_err == ESP_OK && pass_err == ESP_OK;
|
||||
}
|
||||
|
||||
void save_mqtt_credentials(const char *user, const char *pass)
|
||||
{
|
||||
nvs_handle_t handle;
|
||||
ESP_ERROR_CHECK(nvs_open(kNvsNamespace, NVS_READWRITE, &handle));
|
||||
ESP_ERROR_CHECK(nvs_set_str(handle, kNvsKeyUser, user));
|
||||
ESP_ERROR_CHECK(nvs_set_str(handle, kNvsKeyPass, pass));
|
||||
ESP_ERROR_CHECK(nvs_commit(handle));
|
||||
nvs_close(handle);
|
||||
}
|
||||
|
||||
void on_retry_timer(void * /*arg*/)
|
||||
{
|
||||
try_discover_and_connect();
|
||||
}
|
||||
|
||||
void schedule_retry()
|
||||
{
|
||||
if (!g_retry_timer) {
|
||||
const esp_timer_create_args_t timer_args = {
|
||||
.callback = &on_retry_timer,
|
||||
.arg = nullptr,
|
||||
.dispatch_method = ESP_TIMER_TASK,
|
||||
.name = "mqtt_discover_retry",
|
||||
.skip_unhandled_events = true,
|
||||
};
|
||||
ESP_ERROR_CHECK(esp_timer_create(&timer_args, &g_retry_timer));
|
||||
}
|
||||
esp_timer_stop(g_retry_timer); // ignore error: fine if not currently running
|
||||
ESP_ERROR_CHECK(esp_timer_start_once(g_retry_timer, kRediscoverRetryUs));
|
||||
}
|
||||
|
||||
void publish_discovery_and_birth()
|
||||
{
|
||||
char id[16];
|
||||
device_id(id, sizeof(id));
|
||||
|
||||
snprintf(g_availability_topic, sizeof(g_availability_topic), "kvida/%s/status", id);
|
||||
snprintf(g_discovery_topic, sizeof(g_discovery_topic),
|
||||
"homeassistant/binary_sensor/%s/connectivity/config", id);
|
||||
|
||||
snprintf(g_discovery_payload, sizeof(g_discovery_payload),
|
||||
"{"
|
||||
"\"name\":\"Connectivity\","
|
||||
"\"device_class\":\"connectivity\","
|
||||
"\"unique_id\":\"%s_connectivity\","
|
||||
"\"state_topic\":\"%s\","
|
||||
"\"payload_on\":\"online\","
|
||||
"\"payload_off\":\"offline\","
|
||||
"\"availability_topic\":\"%s\","
|
||||
"\"payload_available\":\"online\","
|
||||
"\"payload_not_available\":\"offline\","
|
||||
"\"device\":{"
|
||||
"\"identifiers\":[\"%s\"],"
|
||||
"\"name\":\"%s\","
|
||||
"\"manufacturer\":\"Xylon\","
|
||||
"\"model\":\"Kvida\""
|
||||
"}"
|
||||
"}",
|
||||
id, g_availability_topic, g_availability_topic, id, id);
|
||||
|
||||
esp_mqtt_client_publish(g_client, g_discovery_topic, g_discovery_payload, 0, 1, true);
|
||||
esp_mqtt_client_publish(g_client, g_availability_topic, "online", 0, 1, true);
|
||||
}
|
||||
|
||||
void mqtt_event_handler(void * /*arg*/, esp_event_base_t /*base*/, int32_t event_id, void * /*event_data*/)
|
||||
{
|
||||
switch (static_cast<esp_mqtt_event_id_t>(event_id)) {
|
||||
case MQTT_EVENT_CONNECTED:
|
||||
ESP_LOGI(TAG, "MQTT connected");
|
||||
g_connected.store(true);
|
||||
publish_discovery_and_birth();
|
||||
break;
|
||||
case MQTT_EVENT_DISCONNECTED:
|
||||
ESP_LOGW(TAG, "MQTT disconnected");
|
||||
g_connected.store(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void start_client(const char *broker_uri)
|
||||
{
|
||||
char id[16];
|
||||
device_id(id, sizeof(id));
|
||||
snprintf(g_availability_topic, sizeof(g_availability_topic), "kvida/%s/status", id);
|
||||
|
||||
esp_mqtt_client_config_t cfg = {};
|
||||
cfg.broker.address.uri = broker_uri;
|
||||
cfg.session.last_will.topic = g_availability_topic;
|
||||
cfg.session.last_will.msg = "offline";
|
||||
cfg.session.last_will.msg_len = 0; // 0 -> strlen() of msg
|
||||
cfg.session.last_will.qos = 1;
|
||||
cfg.session.last_will.retain = true;
|
||||
|
||||
// Optional: set only if the user has saved credentials via the
|
||||
// "kvida-xxxxxx.local" settings page (most real brokers, including
|
||||
// Home Assistant's Mosquitto add-on, require auth -- confirmed
|
||||
// against real hardware, see components/transport/README.md).
|
||||
static char user[33];
|
||||
static char pass[65];
|
||||
if (load_mqtt_credentials(user, sizeof(user), pass, sizeof(pass))) {
|
||||
cfg.credentials.username = user;
|
||||
cfg.credentials.authentication.password = pass;
|
||||
}
|
||||
|
||||
if (g_client) {
|
||||
esp_mqtt_client_stop(g_client);
|
||||
esp_mqtt_client_destroy(g_client);
|
||||
g_client = nullptr;
|
||||
}
|
||||
|
||||
g_client = esp_mqtt_client_init(&cfg);
|
||||
esp_mqtt_client_register_event(g_client, static_cast<esp_mqtt_event_id_t>(ESP_EVENT_ANY_ID), &mqtt_event_handler,
|
||||
nullptr);
|
||||
esp_mqtt_client_start(g_client);
|
||||
}
|
||||
|
||||
void try_discover_and_connect()
|
||||
{
|
||||
// A manually-entered broker (settings page) always takes priority
|
||||
// over mDNS discovery: confirmed against real hardware that mDNS
|
||||
// discovery of _mqtt._tcp is not reliable on every network (e.g. a
|
||||
// Home Assistant OS Mosquitto add-on that doesn't advertise itself)
|
||||
// -- see components/transport/README.md.
|
||||
char host[64];
|
||||
uint16_t manual_port = 1883;
|
||||
if (load_manual_broker(host, sizeof(host), &manual_port)) {
|
||||
snprintf(g_broker_uri, sizeof(g_broker_uri), "mqtt://%s:%u", host, manual_port);
|
||||
ESP_LOGI(TAG, "Using manually configured MQTT broker at %s", g_broker_uri);
|
||||
start_client(g_broker_uri);
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "Searching for MQTT broker via mDNS (_mqtt._tcp)...");
|
||||
|
||||
mdns_result_t *results = nullptr;
|
||||
esp_err_t err = mdns_query_ptr("_mqtt", "_tcp", 3000, 1, &results);
|
||||
if (err != ESP_OK || !results || !results->addr) {
|
||||
ESP_LOGW(TAG, "No MQTT broker found via mDNS yet (retrying in 30s) -- this only finds "
|
||||
"brokers that advertise themselves, and enter the broker manually via the "
|
||||
"settings page if it never does");
|
||||
if (results) {
|
||||
mdns_query_results_free(results);
|
||||
}
|
||||
schedule_retry();
|
||||
return;
|
||||
}
|
||||
|
||||
mdns_ip_addr_t *addr = results->addr;
|
||||
while (addr && addr->addr.type != ESP_IPADDR_TYPE_V4) {
|
||||
addr = addr->next;
|
||||
}
|
||||
|
||||
if (!addr) {
|
||||
ESP_LOGW(TAG, "MQTT broker found via mDNS but no IPv4 address (retrying in 30s)");
|
||||
mdns_query_results_free(results);
|
||||
schedule_retry();
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t port = results->port;
|
||||
snprintf(g_broker_uri, sizeof(g_broker_uri), "mqtt://" IPSTR ":%u", IP2STR(&addr->addr.u_addr.ip4), port);
|
||||
ESP_LOGI(TAG, "Found MQTT broker at %s", g_broker_uri);
|
||||
|
||||
mdns_query_results_free(results);
|
||||
|
||||
start_client(g_broker_uri);
|
||||
}
|
||||
|
||||
esp_err_t settings_get_handler(httpd_req_t *req)
|
||||
{
|
||||
char host[64] = {0};
|
||||
uint16_t port = 1883;
|
||||
load_manual_broker(host, sizeof(host), &port);
|
||||
|
||||
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),
|
||||
"<!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_send(req, html, HTTPD_RESP_USE_STRLEN);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t settings_post_handler(httpd_req_t *req)
|
||||
{
|
||||
char body[256];
|
||||
int len = httpd_req_recv(req, body, sizeof(body) - 1);
|
||||
if (len <= 0) {
|
||||
httpd_resp_send_500(req);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
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);
|
||||
|
||||
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);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint16_t port = port_str[0] != '\0' ? static_cast<uint16_t>(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, "<p>Saved. Reconnecting to MQTT...</p>", 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;
|
||||
}
|
||||
|
||||
void start_settings_server()
|
||||
{
|
||||
if (g_settings_httpd) {
|
||||
return;
|
||||
}
|
||||
|
||||
// mDNS must already be initialized (see start_mqtt()) before hostname
|
||||
// advertisement or service registration.
|
||||
char id[16];
|
||||
device_id(id, sizeof(id));
|
||||
|
||||
// mDNS hostname must be lowercase by convention; device_id() returns
|
||||
// e.g. "Kvida-403EC1".
|
||||
char hostname[16];
|
||||
size_t i = 0;
|
||||
for (; id[i] != '\0'; ++i) {
|
||||
hostname[i] = static_cast<char>(tolower(static_cast<unsigned char>(id[i])));
|
||||
}
|
||||
hostname[i] = '\0';
|
||||
|
||||
esp_err_t err = mdns_hostname_set(hostname);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "mdns_hostname_set failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
mdns_instance_name_set(id);
|
||||
mdns_service_add(id, "_http", "_tcp", 80, nullptr, 0);
|
||||
ESP_LOGI(TAG, "Settings page reachable at http://%s.local/", hostname);
|
||||
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.lru_purge_enable = true;
|
||||
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};
|
||||
httpd_register_uri_handler(g_settings_httpd, &root_uri);
|
||||
httpd_register_uri_handler(g_settings_httpd, &mqtt_uri);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void start_mqtt()
|
||||
{
|
||||
static bool started = false;
|
||||
if (started) {
|
||||
return;
|
||||
}
|
||||
started = true;
|
||||
|
||||
esp_err_t err = mdns_init();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "mdns_init failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
// Advertise the settings page hostname first so it's reachable as
|
||||
// soon as possible, before the (up to 3s) broker discovery query.
|
||||
start_settings_server();
|
||||
try_discover_and_connect();
|
||||
}
|
||||
|
||||
bool mqtt_connected()
|
||||
{
|
||||
return g_connected.load();
|
||||
}
|
||||
|
||||
} // namespace kvida
|
||||
28
components/transport/src/url_decode.h
Normal file
28
components/transport/src/url_decode.h
Normal file
@@ -0,0 +1,28 @@
|
||||
#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