From de449d5973679b95b62c0c199ebda4d72f7aeea3 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Sat, 18 Jul 2026 10:48:52 -0400 Subject: [PATCH] Finish captive portal: NVS config, POST handler, STA connect, AP identity Splits provisioning (softAP + captive portal + NVS-backed config) into wifi_provisioning.c and home-network connection into frame_client.c. /save_config now parses the form body and persists it to NVS; on boot the device goes straight to STA mode if a config exists, retrying a few times before falling back to provisioning if the home network is unreachable. The provisioning AP is now always named ESPRESSO with a random per-device password (generated once, persisted in NVS) instead of a fixed Kconfig value, drawn from a charset that avoids visually ambiguous characters since it'll be read off the e-ink panel and possibly typed by hand. --- firmware/main/CMakeLists.txt | 4 +- firmware/main/Kconfig.projbuild | 35 ++- firmware/main/frame_client.c | 114 +++++++++ firmware/main/frame_client.h | 22 ++ firmware/main/main.c | 201 ++-------------- firmware/main/wifi_provisioning.c | 384 ++++++++++++++++++++++++++++++ firmware/main/wifi_provisioning.h | 39 +++ 7 files changed, 610 insertions(+), 189 deletions(-) create mode 100644 firmware/main/frame_client.c create mode 100644 firmware/main/frame_client.h create mode 100644 firmware/main/wifi_provisioning.c create mode 100644 firmware/main/wifi_provisioning.h diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index c1e55e6..1d78629 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -1,3 +1,3 @@ -idf_component_register(SRCS main.c - PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_http_server dns_server +idf_component_register(SRCS main.c wifi_provisioning.c frame_client.c + PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_netif esp_http_server dns_server EMBED_FILES root.html) diff --git a/firmware/main/Kconfig.projbuild b/firmware/main/Kconfig.projbuild index f2df840..e5f3b99 100644 --- a/firmware/main/Kconfig.projbuild +++ b/firmware/main/Kconfig.projbuild @@ -1,16 +1,14 @@ -menu "Example Configuration" +menu "ESPresso Frame Configuration" - config ESP_WIFI_SSID - string "SoftAP SSID" - default "esp32_ssid" + config ESP_AP_SSID + string "Provisioning softAP SSID" + default "ESPRESSO" help - SSID (network name) to set up the softAP with. - - config ESP_WIFI_PASSWORD - string "SoftAP Password" - default "esp32_pwd" - help - WiFi password (WPA or WPA2) for the example to use for the softAP. + SSID of the softAP the device brings up when it needs provisioning. + The password is generated randomly per device on first boot and + shown on the e-ink panel both as a WiFi-join QR code and as + plaintext underneath it, so it can be typed in by hand on a + desktop/laptop that can't scan the QR. config ESP_MAX_STA_CONN int "Maximal STA connections" @@ -29,4 +27,19 @@ menu "Example Configuration" default y help Enables more modern DHCP-based Option 114 to provide clients with the captive portal URI + + config FRAME_STA_CONNECT_MAX_RETRIES + int "Home WiFi connect max retries before falling back to provisioning" + default 3 + help + Number of failed connection attempts to the stored home WiFi network + before the device gives up and re-enters provisioning (softAP) mode. + + config FRAME_STA_CONNECT_TIMEOUT_MS + int "Home WiFi connect timeout per attempt (ms)" + default 15000 + help + How long to wait for an IP address on each connection attempt to the + stored home WiFi network before counting it as a failed retry. + endmenu diff --git a/firmware/main/frame_client.c b/firmware/main/frame_client.c new file mode 100644 index 0000000..4d3ebf7 --- /dev/null +++ b/firmware/main/frame_client.c @@ -0,0 +1,114 @@ +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_wifi.h" +#include "esp_netif.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/event_groups.h" + +#include "frame_client.h" + +static const char *TAG = "frame_client"; + +#define STA_CONNECTED_BIT BIT0 +#define STA_FAILED_BIT BIT1 + +static EventGroupHandle_t s_sta_event_group; + +/* wifi_sta_config_t's ssid/password fields are fixed-size byte arrays, not + * necessarily null-terminated (a full 32-char SSID fills the field exactly). + * snprintf() flags that as a possible truncation at -Werror, so copy by + * hand instead. */ +static void copy_wifi_field(uint8_t *dst, size_t dst_size, const char *src) +{ + size_t len = strnlen(src, dst_size); + memcpy(dst, src, len); + if (len < dst_size) { + dst[len] = '\0'; + } +} + +static void sta_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { + esp_wifi_connect(); + } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { + ESP_LOGW(TAG, "Disconnected from home WiFi"); + xEventGroupSetBits(s_sta_event_group, STA_FAILED_BIT); + } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { + ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; + ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip)); + xEventGroupSetBits(s_sta_event_group, STA_CONNECTED_BIT); + } +} + +esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg) +{ + s_sta_event_group = xEventGroupCreate(); + + esp_netif_create_default_wifi_sta(); + + wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&init_cfg)); + + esp_event_handler_instance_t wifi_handler; + esp_event_handler_instance_t ip_handler; + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &sta_event_handler, NULL, &wifi_handler)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, NULL, &ip_handler)); + + wifi_config_t wifi_config = {0}; + copy_wifi_field(wifi_config.sta.ssid, sizeof(wifi_config.sta.ssid), cfg->sta_ssid); + copy_wifi_field(wifi_config.sta.password, sizeof(wifi_config.sta.password), cfg->sta_password); + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + esp_err_t result = ESP_FAIL; + for (int attempt = 1; attempt <= CONFIG_FRAME_STA_CONNECT_MAX_RETRIES; attempt++) { + ESP_LOGI(TAG, "Connecting to '%s' (attempt %d/%d)", cfg->sta_ssid, attempt, + CONFIG_FRAME_STA_CONNECT_MAX_RETRIES); + + xEventGroupClearBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT); + esp_wifi_connect(); + + EventBits_t bits = xEventGroupWaitBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT, + pdTRUE, pdFALSE, + pdMS_TO_TICKS(CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS)); + + if (bits & STA_CONNECTED_BIT) { + result = ESP_OK; + break; + } + ESP_LOGW(TAG, "Attempt %d/%d failed", attempt, CONFIG_FRAME_STA_CONNECT_MAX_RETRIES); + } + + esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_handler); + esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, ip_handler); + vEventGroupDelete(s_sta_event_group); + s_sta_event_group = NULL; + + if (result != ESP_OK) { + esp_wifi_stop(); + } + + return result; +} + +void frame_client_run(const frame_config_t *cfg) +{ + /* TODO(step 6): HTTP fetch from cfg->toolsserver -> epd_write_stream -> + * epd_refresh -> esp_deep_sleep. Idling here for now so the device + * stays observable over serial while STA connectivity is verified. */ + ESP_LOGI(TAG, "Connected to home WiFi. toolsserver='%s' (fetch/display cycle not yet implemented)", + cfg->toolsserver); + + while (1) { + ESP_LOGI(TAG, "heartbeat: still connected"); + vTaskDelay(pdMS_TO_TICKS(60000)); + } +} diff --git a/firmware/main/frame_client.h b/firmware/main/frame_client.h new file mode 100644 index 0000000..77915f8 --- /dev/null +++ b/firmware/main/frame_client.h @@ -0,0 +1,22 @@ +#pragma once + +#include "esp_err.h" +#include "wifi_provisioning.h" + +/** + * Connects to the home WiFi network described by cfg, retrying up to + * CONFIG_FRAME_STA_CONNECT_MAX_RETRIES times with a per-attempt timeout of + * CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS. Returns ESP_OK once an IP address + * has been obtained, ESP_FAIL if all retries are exhausted. + */ +esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg); + +/** + * Runs the frame's normal-operation cycle: fetch the current image from + * cfg->toolsserver, display it, and deep-sleep until the next refresh. + * + * TODO(step 6): implement the HTTP fetch + EPD display + esp_deep_sleep + * cycle once the display driver (step 4) and server (step 3) exist. For + * now this just confirms STA connectivity survives a reboot. + */ +void frame_client_run(const frame_config_t *cfg); diff --git a/firmware/main/main.c b/firmware/main/main.c index b52040e..5e03290 100644 --- a/firmware/main/main.c +++ b/firmware/main/main.c @@ -1,195 +1,44 @@ -/* Captive Portal Example - - This example code is in the Public Domain (or CC0 licensed, at your option.) - - Unless required by applicable law or agreed to in writing, this - software is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR - CONDITIONS OF ANY KIND, either express or implied. -*/ - -#include - #include "esp_event.h" #include "esp_log.h" -#include "esp_mac.h" - #include "nvs_flash.h" -#include "esp_wifi.h" #include "esp_netif.h" -#include "lwip/inet.h" -#include "esp_http_server.h" -#include "dns_server.h" +#include "wifi_provisioning.h" +#include "frame_client.h" -#define EXAMPLE_ESP_WIFI_SSID CONFIG_ESP_WIFI_SSID -#define EXAMPLE_ESP_WIFI_PASS CONFIG_ESP_WIFI_PASSWORD -#define EXAMPLE_MAX_STA_CONN CONFIG_ESP_MAX_STA_CONN - -extern const char root_start[] asm("_binary_root_html_start"); -extern const char root_end[] asm("_binary_root_html_end"); - -static const char *TAG = "example"; - -static void wifi_event_handler(void *arg, esp_event_base_t event_base, - int32_t event_id, void *event_data) -{ - if (event_id == WIFI_EVENT_AP_STACONNECTED) { - wifi_event_ap_staconnected_t *event = (wifi_event_ap_staconnected_t *)event_data; - ESP_LOGI(TAG, "station " MACSTR " join, AID=%d", - MAC2STR(event->mac), event->aid); - } else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) { - wifi_event_ap_stadisconnected_t *event = (wifi_event_ap_stadisconnected_t *)event_data; - ESP_LOGI(TAG, "station " MACSTR " leave, AID=%d, reason=%d", - MAC2STR(event->mac), event->aid, event->reason); - } -} - -static void wifi_init_softap(void) -{ - wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); - ESP_ERROR_CHECK(esp_wifi_init(&cfg)); - - ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL)); - - wifi_config_t wifi_config = { - .ap = { - .ssid = EXAMPLE_ESP_WIFI_SSID, - .ssid_len = strlen(EXAMPLE_ESP_WIFI_SSID), - .password = EXAMPLE_ESP_WIFI_PASS, - .max_connection = EXAMPLE_MAX_STA_CONN, - .authmode = WIFI_AUTH_WPA_WPA2_PSK - }, - }; - if (strlen(EXAMPLE_ESP_WIFI_PASS) == 0) { - wifi_config.ap.authmode = WIFI_AUTH_OPEN; - } - - ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); - ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config)); - ESP_ERROR_CHECK(esp_wifi_start()); - - esp_netif_ip_info_t ip_info; - esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), &ip_info); - - char ip_addr[16]; - inet_ntoa_r(ip_info.ip.addr, ip_addr, 16); - ESP_LOGI(TAG, "Set up softAP with IP: %s", ip_addr); - - ESP_LOGI(TAG, "wifi_init_softap finished. SSID:'%s' password:'%s'", - EXAMPLE_ESP_WIFI_SSID, EXAMPLE_ESP_WIFI_PASS); -} - -#ifdef CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL -static void dhcp_set_captiveportal_url(void) { - // get the IP of the access point to redirect to - esp_netif_ip_info_t ip_info; - esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), &ip_info); - - char ip_addr[16]; - inet_ntoa_r(ip_info.ip.addr, ip_addr, 16); - ESP_LOGI(TAG, "Set up softAP with IP: %s", ip_addr); - - // turn the IP into a URI - char* captiveportal_uri = (char*) malloc(32 * sizeof(char)); - assert(captiveportal_uri && "Failed to allocate captiveportal_uri"); - strcpy(captiveportal_uri, "http://"); - strcat(captiveportal_uri, ip_addr); - - // get a handle to configure DHCP with - esp_netif_t* netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); - - // set the DHCP option 114 - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_stop(netif)); - ESP_ERROR_CHECK(esp_netif_dhcps_option(netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, captiveportal_uri, strlen(captiveportal_uri))); - ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_start(netif)); -} -#endif // CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL - -// HTTP GET Handler -static esp_err_t root_get_handler(httpd_req_t *req) -{ - const uint32_t root_len = root_end - root_start; - - ESP_LOGI(TAG, "Serve root"); - httpd_resp_set_type(req, "text/html"); - httpd_resp_send(req, root_start, root_len); - - return ESP_OK; -} - -static const httpd_uri_t root = { - .uri = "/", - .method = HTTP_GET, - .handler = root_get_handler -}; - -// HTTP Error (404) Handler - Redirects all requests to the root page -esp_err_t http_404_error_handler(httpd_req_t *req, httpd_err_code_t err) -{ - // Set status - httpd_resp_set_status(req, "303 See Other"); - // Redirect to the "/" root directory - httpd_resp_set_hdr(req, "Location", "/"); - // iOS requires content in the response to detect a captive portal, simply redirecting is not sufficient. - httpd_resp_send(req, "Redirect to the captive portal", HTTPD_RESP_USE_STRLEN); - - ESP_LOGI(TAG, "Redirecting to root"); - return ESP_OK; -} - -static httpd_handle_t start_webserver(void) -{ - httpd_handle_t server = NULL; - httpd_config_t config = HTTPD_DEFAULT_CONFIG(); - config.max_open_sockets = 13; - config.lru_purge_enable = true; - - // Start the httpd server - ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port); - if (httpd_start(&server, &config) == ESP_OK) { - // Set URI handlers - ESP_LOGI(TAG, "Registering URI handlers"); - httpd_register_uri_handler(server, &root); - httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, http_404_error_handler); - } - return server; -} +static const char *TAG = "main"; void app_main(void) { - /* - Turn of warnings from HTTP server as redirecting traffic will yield - lots of invalid requests - */ + /* Redirected/invalid captive-portal traffic generates a lot of noise + * at the default log level. */ esp_log_level_set("httpd_uri", ESP_LOG_ERROR); esp_log_level_set("httpd_txrx", ESP_LOG_ERROR); esp_log_level_set("httpd_parse", ESP_LOG_ERROR); - - // Initialize networking stack ESP_ERROR_CHECK(esp_netif_init()); - - // Create default event loop needed by the main app ESP_ERROR_CHECK(esp_event_loop_create_default()); - // Initialize NVS needed by Wi-Fi - ESP_ERROR_CHECK(nvs_flash_init()); + esp_err_t nvs_err = nvs_flash_init(); + if (nvs_err == ESP_ERR_NVS_NO_FREE_PAGES || nvs_err == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_ERROR_CHECK(nvs_flash_erase()); + nvs_err = nvs_flash_init(); + } + ESP_ERROR_CHECK(nvs_err); - // Initialize Wi-Fi including netif with default config - esp_netif_create_default_wifi_ap(); + frame_config_t cfg; + esp_err_t cfg_err = frame_config_load(&cfg); + if (cfg_err == ESP_OK) { + ESP_LOGI(TAG, "Found stored config for '%s', connecting to home WiFi", cfg.sta_ssid); + if (frame_wifi_connect_sta(&cfg) == ESP_OK) { + frame_client_run(&cfg); + return; /* frame_client_run currently never returns */ + } + ESP_LOGW(TAG, "Could not connect to stored WiFi after %d attempts, falling back to provisioning", + CONFIG_FRAME_STA_CONNECT_MAX_RETRIES); + } else { + ESP_LOGI(TAG, "No stored config found (%s), starting provisioning", esp_err_to_name(cfg_err)); + } - // Initialise ESP32 in SoftAP mode - wifi_init_softap(); - - // Configure DNS-based captive portal, if configured - #ifdef CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL - dhcp_set_captiveportal_url(); - #endif - - // Start the server for the first time - start_webserver(); - - // Start the DNS server that will redirect all queries to the softAP IP - dns_server_config_t config = DNS_SERVER_CONFIG_SINGLE("*" /* all A queries */, "WIFI_AP_DEF" /* softAP netif ID */); - start_dns_server(&config); + wifi_provisioning_start(); } diff --git a/firmware/main/wifi_provisioning.c b/firmware/main/wifi_provisioning.c new file mode 100644 index 0000000..9596019 --- /dev/null +++ b/firmware/main/wifi_provisioning.c @@ -0,0 +1,384 @@ +#include +#include +#include +#include + +#include "esp_event.h" +#include "esp_log.h" +#include "esp_mac.h" +#include "esp_random.h" + +#include "nvs_flash.h" +#include "esp_wifi.h" +#include "esp_netif.h" +#include "lwip/inet.h" + +#include "esp_http_server.h" +#include "dns_server.h" + +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" + +#include "wifi_provisioning.h" + +#define NVS_NAMESPACE "frame_cfg" + +extern const char root_start[] asm("_binary_root_html_start"); +extern const char root_end[] asm("_binary_root_html_end"); + +static const char *TAG = "wifi_provisioning"; + +/* Uppercase letters + digits, excluding visually ambiguous characters + * (0/O, 1/I/L) so a human can read the password off the panel and type it + * on a desktop/laptop that can't scan the QR code. */ +static const char AP_PASSWORD_CHARSET[] = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + +/* ------------------------------------------------------------------------ + * NVS-backed config + * ---------------------------------------------------------------------- */ + +esp_err_t frame_config_load(frame_config_t *out) +{ + memset(out, 0, sizeof(*out)); + + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READONLY, &handle); + if (err != ESP_OK) { + return err; + } + + size_t len = sizeof(out->sta_ssid); + err = nvs_get_str(handle, "sta_ssid", out->sta_ssid, &len); + if (err != ESP_OK) { + nvs_close(handle); + return err; + } + + len = sizeof(out->toolsserver); + err = nvs_get_str(handle, "toolsserver", out->toolsserver, &len); + if (err != ESP_OK) { + nvs_close(handle); + return err; + } + + /* Password is allowed to be empty (open home network), so a missing + * key just means "no password" rather than "not provisioned". */ + len = sizeof(out->sta_password); + esp_err_t pass_err = nvs_get_str(handle, "sta_pass", out->sta_password, &len); + if (pass_err != ESP_OK && pass_err != ESP_ERR_NVS_NOT_FOUND) { + nvs_close(handle); + return pass_err; + } + + nvs_close(handle); + return ESP_OK; +} + +esp_err_t frame_config_save(const frame_config_t *cfg) +{ + nvs_handle_t handle; + esp_err_t err = nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + return err; + } + + err = nvs_set_str(handle, "sta_ssid", cfg->sta_ssid); + if (err == ESP_OK) { + err = nvs_set_str(handle, "sta_pass", cfg->sta_password); + } + if (err == ESP_OK) { + err = nvs_set_str(handle, "toolsserver", cfg->toolsserver); + } + if (err == ESP_OK) { + err = nvs_commit(handle); + } + + nvs_close(handle); + return err; +} + +static void generate_ap_password(char *out, size_t out_size) +{ + size_t len = MIN(FRAME_AP_PASSWORD_LEN, out_size - 1); + uint8_t random_bytes[FRAME_AP_PASSWORD_LEN]; + esp_fill_random(random_bytes, len); + + size_t charset_len = strlen(AP_PASSWORD_CHARSET); + for (size_t i = 0; i < len; i++) { + out[i] = AP_PASSWORD_CHARSET[random_bytes[i] % charset_len]; + } + out[len] = '\0'; +} + +void ap_identity_get(char *ssid_out, size_t ssid_len, char *pass_out, size_t pass_len) +{ + snprintf(ssid_out, ssid_len, "%s", CONFIG_ESP_AP_SSID); + + nvs_handle_t handle; + ESP_ERROR_CHECK(nvs_open(NVS_NAMESPACE, NVS_READWRITE, &handle)); + + size_t len = pass_len; + esp_err_t err = nvs_get_str(handle, "ap_pass", pass_out, &len); + if (err == ESP_ERR_NVS_NOT_FOUND) { + generate_ap_password(pass_out, pass_len); + ESP_ERROR_CHECK(nvs_set_str(handle, "ap_pass", pass_out)); + ESP_ERROR_CHECK(nvs_commit(handle)); + } else { + ESP_ERROR_CHECK(err); + } + + nvs_close(handle); +} + +/* ------------------------------------------------------------------------ + * Tiny application/x-www-form-urlencoded parser for the provisioning form + * ---------------------------------------------------------------------- */ + +static void url_decode(char *dst, const char *src, size_t dst_size) +{ + size_t di = 0; + for (size_t si = 0; src[si] != '\0' && di + 1 < dst_size; si++) { + char c = src[si]; + if (c == '+') { + dst[di++] = ' '; + } else if (c == '%' && isxdigit((unsigned char)src[si + 1]) && isxdigit((unsigned char)src[si + 2])) { + char hex[3] = { src[si + 1], src[si + 2], '\0' }; + dst[di++] = (char) strtol(hex, NULL, 16); + si += 2; + } else { + dst[di++] = c; + } + } + dst[di] = '\0'; +} + +static void extract_form_value(const char *body, const char *key, char *out, size_t out_size) +{ + out[0] = '\0'; + size_t key_len = strlen(key); + const char *p = body; + while (p != NULL && *p != '\0') { + const char *amp = strchr(p, '&'); + size_t token_len = amp ? (size_t)(amp - p) : strlen(p); + if (token_len > key_len && p[key_len] == '=' && strncmp(p, key, key_len) == 0) { + char raw[FRAME_CFG_SERVER_MAX_LEN + 1] = {0}; + size_t value_len = token_len - key_len - 1; + if (value_len > sizeof(raw) - 1) { + value_len = sizeof(raw) - 1; + } + memcpy(raw, p + key_len + 1, value_len); + raw[value_len] = '\0'; + url_decode(out, raw, out_size); + return; + } + if (amp == NULL) { + break; + } + p = amp + 1; + } +} + +/* ------------------------------------------------------------------------ + * HTTP handlers + * ---------------------------------------------------------------------- */ + +static esp_err_t root_get_handler(httpd_req_t *req) +{ + const uint32_t root_len = root_end - root_start; + + ESP_LOGI(TAG, "Serve root"); + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, root_start, root_len); + + return ESP_OK; +} + +static const httpd_uri_t root = { + .uri = "/", + .method = HTTP_GET, + .handler = root_get_handler +}; + +static esp_err_t save_config_post_handler(httpd_req_t *req) +{ + if (req->content_len <= 0 || req->content_len > 512) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Form data too large"); + return ESP_FAIL; + } + + char body[513]; + int received = 0; + while (received < req->content_len) { + int ret = httpd_req_recv(req, body + received, req->content_len - received); + if (ret == HTTPD_SOCK_ERR_TIMEOUT) { + continue; + } + if (ret <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "Failed to read body"); + return ESP_FAIL; + } + received += ret; + } + body[received] = '\0'; + + frame_config_t cfg = {0}; + extract_form_value(body, "ssid", cfg.sta_ssid, sizeof(cfg.sta_ssid)); + extract_form_value(body, "password", cfg.sta_password, sizeof(cfg.sta_password)); + extract_form_value(body, "toolsserver", cfg.toolsserver, sizeof(cfg.toolsserver)); + + if (strlen(cfg.sta_ssid) == 0 || strlen(cfg.toolsserver) == 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "SSID and Tools Server are required"); + return ESP_FAIL; + } + + esp_err_t err = frame_config_save(&cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to save config: %s", esp_err_to_name(err)); + httpd_resp_send_500(req); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Saved config: ssid='%s' toolsserver='%s'", cfg.sta_ssid, cfg.toolsserver); + + static const char resp[] = + "

Saved. Restarting and connecting to your WiFi...

"; + httpd_resp_set_type(req, "text/html"); + httpd_resp_send(req, resp, HTTPD_RESP_USE_STRLEN); + + /* Let the response flush to the client before rebooting into STA mode. */ + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); + + return ESP_OK; +} + +static const httpd_uri_t save_config = { + .uri = "/save_config", + .method = HTTP_POST, + .handler = save_config_post_handler +}; + +/* Redirects all unmatched requests to the captive portal root page. */ +static esp_err_t http_404_error_handler(httpd_req_t *req, httpd_err_code_t err) +{ + httpd_resp_set_status(req, "303 See Other"); + httpd_resp_set_hdr(req, "Location", "/"); + /* iOS requires content in the response to detect a captive portal. */ + httpd_resp_send(req, "Redirect to the captive portal", HTTPD_RESP_USE_STRLEN); + + ESP_LOGI(TAG, "Redirecting to root"); + return ESP_OK; +} + +static httpd_handle_t start_webserver(void) +{ + httpd_handle_t server = NULL; + httpd_config_t config = HTTPD_DEFAULT_CONFIG(); + config.max_open_sockets = 13; + config.lru_purge_enable = true; + + ESP_LOGI(TAG, "Starting server on port: '%d'", config.server_port); + if (httpd_start(&server, &config) == ESP_OK) { + ESP_LOGI(TAG, "Registering URI handlers"); + httpd_register_uri_handler(server, &root); + httpd_register_uri_handler(server, &save_config); + httpd_register_err_handler(server, HTTPD_404_NOT_FOUND, http_404_error_handler); + } + return server; +} + +/* ------------------------------------------------------------------------ + * softAP + captive portal bring-up + * ---------------------------------------------------------------------- */ + +static void wifi_event_handler(void *arg, esp_event_base_t event_base, + int32_t event_id, void *event_data) +{ + if (event_id == WIFI_EVENT_AP_STACONNECTED) { + wifi_event_ap_staconnected_t *event = (wifi_event_ap_staconnected_t *)event_data; + ESP_LOGI(TAG, "station " MACSTR " join, AID=%d", MAC2STR(event->mac), event->aid); + } else if (event_id == WIFI_EVENT_AP_STADISCONNECTED) { + wifi_event_ap_stadisconnected_t *event = (wifi_event_ap_stadisconnected_t *)event_data; + ESP_LOGI(TAG, "station " MACSTR " leave, AID=%d, reason=%d", + MAC2STR(event->mac), event->aid, event->reason); + } +} + +static void wifi_init_softap(const char *ap_ssid, const char *ap_password) +{ + wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT(); + ESP_ERROR_CHECK(esp_wifi_init(&cfg)); + + ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &wifi_event_handler, NULL)); + + wifi_config_t wifi_config = { + .ap = { + .max_connection = CONFIG_ESP_MAX_STA_CONN, + .authmode = WIFI_AUTH_WPA_WPA2_PSK + }, + }; + snprintf((char *)wifi_config.ap.ssid, sizeof(wifi_config.ap.ssid), "%s", ap_ssid); + wifi_config.ap.ssid_len = strlen(ap_ssid); + snprintf((char *)wifi_config.ap.password, sizeof(wifi_config.ap.password), "%s", ap_password); + + ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_AP)); + ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_AP, &wifi_config)); + ESP_ERROR_CHECK(esp_wifi_start()); + + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), &ip_info); + + char ip_addr[16]; + inet_ntoa_r(ip_info.ip.addr, ip_addr, 16); + ESP_LOGI(TAG, "Set up softAP with IP: %s", ip_addr); + ESP_LOGI(TAG, "wifi_init_softap finished. SSID:'%s' password:'%s'", ap_ssid, ap_password); +} + +#ifdef CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL +static void dhcp_set_captiveportal_url(void) +{ + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"), &ip_info); + + char ip_addr[16]; + inet_ntoa_r(ip_info.ip.addr, ip_addr, 16); + + char *captiveportal_uri = (char *) malloc(32 * sizeof(char)); + assert(captiveportal_uri && "Failed to allocate captiveportal_uri"); + strcpy(captiveportal_uri, "http://"); + strcat(captiveportal_uri, ip_addr); + + esp_netif_t *netif = esp_netif_get_handle_from_ifkey("WIFI_AP_DEF"); + + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_stop(netif)); + ESP_ERROR_CHECK(esp_netif_dhcps_option(netif, ESP_NETIF_OP_SET, ESP_NETIF_CAPTIVEPORTAL_URI, + captiveportal_uri, strlen(captiveportal_uri))); + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_dhcps_start(netif)); + + free(captiveportal_uri); +} +#endif // CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL + +void wifi_provisioning_start(void) +{ + char ap_ssid[FRAME_CFG_SSID_MAX_LEN + 1]; + char ap_password[FRAME_AP_PASSWORD_LEN + 1]; + ap_identity_get(ap_ssid, sizeof(ap_ssid), ap_password, sizeof(ap_password)); + + /* TODO(step 5): draw the WiFi-join QR code + plaintext password on the + * e-ink panel here, before the AP goes up, so the join instructions are + * always visible by the time the network is joinable. */ + ESP_LOGI(TAG, "Provisioning AP: SSID='%s' password='%s'", ap_ssid, ap_password); + + esp_netif_create_default_wifi_ap(); + wifi_init_softap(ap_ssid, ap_password); + +#ifdef CONFIG_ESP_ENABLE_DHCP_CAPTIVEPORTAL + dhcp_set_captiveportal_url(); +#endif + + start_webserver(); + + dns_server_config_t dns_config = DNS_SERVER_CONFIG_SINGLE("*" /* all A queries */, "WIFI_AP_DEF"); + start_dns_server(&dns_config); +} diff --git a/firmware/main/wifi_provisioning.h b/firmware/main/wifi_provisioning.h new file mode 100644 index 0000000..89cfedc --- /dev/null +++ b/firmware/main/wifi_provisioning.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include "esp_err.h" + +#define FRAME_CFG_SSID_MAX_LEN 32 +#define FRAME_CFG_PASSWORD_MAX_LEN 64 +#define FRAME_CFG_SERVER_MAX_LEN 128 +#define FRAME_AP_PASSWORD_LEN 10 + +typedef struct { + char sta_ssid[FRAME_CFG_SSID_MAX_LEN + 1]; + char sta_password[FRAME_CFG_PASSWORD_MAX_LEN + 1]; + char toolsserver[FRAME_CFG_SERVER_MAX_LEN + 1]; +} frame_config_t; + +/** + * Loads the saved home-network config from NVS. + * Returns ESP_ERR_NVS_NOT_FOUND if the device has never been provisioned. + */ +esp_err_t frame_config_load(frame_config_t *out); + +/** Saves the home-network config to NVS. */ +esp_err_t frame_config_save(const frame_config_t *cfg); + +/** + * Returns this device's provisioning AP identity: a fixed SSID (from + * Kconfig) and a password that's generated once on first use and persisted + * in NVS from then on. The password is drawn from an easy-to-type charset + * since it's shown on the e-ink panel (as both a QR code and plaintext) and + * may need to be typed in by hand. + */ +void ap_identity_get(char *ssid_out, size_t ssid_len, char *pass_out, size_t pass_len); + +/** + * Brings up the ESPRESSO softAP + captive portal (DNS + HTTP) so the user + * can provision the device. Does not return. + */ +void wifi_provisioning_start(void);