diff --git a/components/transport/CMakeLists.txt b/components/transport/CMakeLists.txt index 97b67a7..648b9ee 100644 --- a/components/transport/CMakeLists.txt +++ b/components/transport/CMakeLists.txt @@ -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" ) diff --git a/components/transport/README.md b/components/transport/README.md index fe54d67..23bc1ce 100644 --- a/components/transport/README.md +++ b/components/transport/README.md @@ -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. diff --git a/components/transport/idf_component.yml b/components/transport/idf_component.yml new file mode 100644 index 0000000..849711d --- /dev/null +++ b/components/transport/idf_component.yml @@ -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: "*" diff --git a/components/transport/include/mqtt.h b/components/transport/include/mqtt.h new file mode 100644 index 0000000..dd1c10e --- /dev/null +++ b/components/transport/include/mqtt.h @@ -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 diff --git a/components/transport/src/commissioning.cpp b/components/transport/src/commissioning.cpp index e5d9e96..86f9f9c 100644 --- a/components/transport/src/commissioning.cpp +++ b/components/transport/src/commissioning.cpp @@ -1,10 +1,12 @@ #include "commissioning.h" #include -#include #include +#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(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(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(ap_config.ap.ssid), sizeof(ap_config.ap.ssid), - "Kvida-%02X%02X%02X", mac[3], mac[4], mac[5]); + int len = snprintf(reinterpret_cast(ap_config.ap.ssid), sizeof(ap_config.ap.ssid), "%s", id); ap_config.ap.ssid_len = static_cast(len); ap_config.ap.max_connection = 4; // Open AP by design: this is a short-lived, physically-local-only setup diff --git a/components/transport/src/device_id.h b/components/transport/src/device_id.h new file mode 100644 index 0000000..294c853 --- /dev/null +++ b/components/transport/src/device_id.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +#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 diff --git a/components/transport/src/mqtt.cpp b/components/transport/src/mqtt.cpp new file mode 100644 index 0000000..ec4e9ce --- /dev/null +++ b/components/transport/src/mqtt.cpp @@ -0,0 +1,414 @@ +#include "mqtt.h" + +#include +#include +#include +#include +#include + +#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 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(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_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), + "" + "" + "Kvida settings" + "" + "" + "

MQTT settings

" + "
" + "" + "" + "" + "" + "" + "
", + 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(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); + + 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(tolower(static_cast(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 diff --git a/components/transport/src/url_decode.h b/components/transport/src/url_decode.h new file mode 100644 index 0000000..0eb9fb9 --- /dev/null +++ b/components/transport/src/url_decode.h @@ -0,0 +1,28 @@ +#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