commit cb50c99c98f17642b09ffc10c13dd9bbbcd1e222 Author: Thomas Faour Date: Sat Jul 18 10:20:25 2026 -0400 Restructure repo as monorepo: firmware/ + server/ + docs/ Moves the existing ESP-IDF captive_portal example into firmware/ to make room for the new FastAPI server and project docs, ahead of building out the full ESPresso Frame project (ESP32-C6 + Immich-backed e-ink photo frame). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5c707f --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# ESP-IDF firmware build output +firmware/build/ +firmware/sdkconfig +firmware/sdkconfig.old +firmware/managed_components/ +firmware/dependencies.lock + +# Python server +server/__pycache__/ +server/**/__pycache__/ +server/.venv/ +server/*.egg-info/ +server/data/ +.env + +# Editor / OS cruft +.vscode/ +.idea/ +*.swp +.DS_Store diff --git a/firmware/CMakeLists.txt b/firmware/CMakeLists.txt new file mode 100644 index 0000000..6d534b9 --- /dev/null +++ b/firmware/CMakeLists.txt @@ -0,0 +1,8 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.22) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +# "Trim" the build. Include the minimal set of components, main, and anything it depends on. +idf_build_set_property(MINIMAL_BUILD ON) +project(espresso_frame) diff --git a/firmware/README.md b/firmware/README.md new file mode 100644 index 0000000..7b13b55 --- /dev/null +++ b/firmware/README.md @@ -0,0 +1,129 @@ +| Supported Targets | ESP32 | ESP32-C2 | ESP32-C3 | ESP32-C5 | ESP32-C6 | ESP32-C61 | ESP32-S2 | ESP32-S3 | +| ----------------- | ----- | -------- | -------- | -------- | -------- | --------- | -------- | -------- | + +# Captive Portal Example + +(See the README.md file in the upper level 'examples' directory for more information about examples.) + +This example demonstrates two methods of a captive portal, used to direct users to an authentication page or other necessary starting point before browsing. + +One approach response to all DNS queries with the address of the softAP, and redirects all HTTP requests to the captive portal root page. This "funnelling" of DNS and traffic triggers the captive portal (sign in) to appear on Android, iOS, and Windows. Note that the example will not redirect HTTPS requests. + +The other approach is a more modern method which includes a field in the DHCP offer (AKA DHCP Option 114), provided when the client is assigned an IP address, which specifies to the client where the captive portal is. This is advantageous because it doesn't require the overhead of DNS redirects and can work more reliably around HTTPS, HTST, and other security systems, as well as being more standards compliant. This feature is toggleable in the `Example Configuration`, but does not conflict with the DNS methodology -- these two methods work towards the same goal and can complement each other. + +## How to Use Example + +Before project configuration and build, be sure to set the correct chip target using `idf.py set-target `. + +### Hardware Required + +* A development board with ESP32/ESP32-S2/ESP32-C3 SoC (e.g., ESP32-DevKitC, ESP-WROVER-KIT, etc.) +* A USB cable for power supply and programming +* WiFi interface + +### Configure the project + +Open the project configuration menu (`idf.py menuconfig`). + +In the `Example Configuration` menu: + +* Set the Wi-Fi configuration. + * Set `SoftAP SSID` + * Set `SoftAP Password` + * Set `Maximal STA connections` + * Set `DHCP Captive portal` to enable or disable DHCP Option 114 + +### Build and Flash + +Build the project and flash it to the board, then run monitor tool to view serial output: + +``` +idf.py -p PORT flash monitor +``` + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. + +## Example Output + + +### ESP32 Output for DNS Redirect + +``` +I (733) example: Set up softAP with IP: 192.168.4.1 +I (743) example: wifi_init_softap finished. SSID:'esp32_ssid' password:'esp32_pwd' +I (753) example: Starting server on port: '80' +I (753) example: Registering URI handlers +I (763) example_dns_redirect_server: Socket created +I (763) example_dns_redirect_server: Socket bound, port 53 +I (773) example_dns_redirect_server: Waiting for data +I (1873) wifi:new:<1,1>, old:<1,1>, ap:<1,1>, sta:<255,255>, prof:1 +I (1873) wifi:station: e8:84:a5:18:8f:80 join, AID=1, bgn, 40U +I (2203) example: station e8:84:a5:18:8f:80 join, AID=1 +I (2833) example_dns_redirect_server: Received 50 bytes from 192.168.4.2 | DNS reply with len: 66 +I (2843) example_dns_redirect_server: Waiting for data +I (3043) example_dns_redirect_server: Received 39 bytes from 192.168.4.2 | DNS reply with len: 55 +I (3043) example_dns_redirect_server: Waiting for data +I (3043) example_dns_redirect_server: Received 42 bytes from 192.168.4.2 | DNS reply with len: 58 +I (3053) example_dns_redirect_server: Waiting for data +W (3203) wifi:idx:4 (ifx:1, e8:84:a5:18:8f:80), tid:0, ssn:9, winSize:64 +I (3533) example: Redirecting to root +I (5693) example_dns_redirect_server: Received 37 bytes from 192.168.4.2 | DNS reply with len: 53 +I (5693) example_dns_redirect_server: Waiting for data +I (5783) example_dns_redirect_server: Received 46 bytes from 192.168.4.2 | DNS reply with len: 62 +I (5783) example_dns_redirect_server: Waiting for data +I (6303) example_dns_redirect_server: Received 41 bytes from 192.168.4.2 | DNS reply with len: 57 +I (6303) example_dns_redirect_server: Waiting for data +I (6303) example_dns_redirect_server: Received 41 bytes from 192.168.4.2 | DNS reply with len: 57 +I (6313) example_dns_redirect_server: Waiting for data +I (6593) example: Redirecting to root +I (9623) example: Redirecting to root +I (12913) example: Redirecting to root +I (13263) example_dns_redirect_server: Received 34 bytes from 192.168.4.2 | DNS reply with len: 50 +I (13273) example_dns_redirect_server: Waiting for data +I (13273) example_dns_redirect_server: Received 34 bytes from 192.168.4.2 | DNS reply with len: 50 +I (13283) example_dns_redirect_server: Waiting for data +I (16303) example_dns_redirect_server: Received 32 bytes from 192.168.4.2 | DNS reply with len: 48 +I (16303) example_dns_redirect_server: Waiting for data +I (18073) example: Redirecting to root +I (18273) example_dns_redirect_server: Received 34 bytes from 192.168.4.2 | DNS reply with len: 50 +I (18273) example_dns_redirect_server: Waiting for data +I (18273) example_dns_redirect_server: Received 34 bytes from 192.168.4.2 | DNS reply with len: 50 +I (18283) example_dns_redirect_server: Waiting for data +I (20683) example_dns_redirect_server: Received 42 bytes from 192.168.4.2 | DNS reply with len: 58 +I (20683) example_dns_redirect_server: Waiting for data +I (20753) example: Redirecting to root +I (21323) example: Redirecting to root +I (22683) example_dns_redirect_server: Received 48 bytes from 192.168.4.2 | DNS reply with len: 64 +I (22693) example_dns_redirect_server: Waiting for data +I (23443) example_dns_redirect_server: Received 48 bytes from 192.168.4.2 | DNS reply with len: 64 +I (23453) example_dns_redirect_server: Waiting for data +I (23473) example: Serve root +I (23503) example_dns_redirect_server: Received 48 bytes from 192.168.4.2 | DNS reply with len: 64 +I (23513) example_dns_redirect_server: Waiting for data +``` + +### `tcpdump` Output for DHCP Option 114 + +Note `URL (114)` with the AP address. + +``` +19:14:20.522698 c8:yy:yy:yy:yy:yy > 74:xx:xx:xx:xx:xx, ethertype IPv4 (0x0800), length 590: (tos 0x0, ttl 64, id 243, offset 0, flags [none], proto UDP (17), length 576) + 192.168.4.1.67 > 192.168.4.2.68: [udp sum ok] BOOTP/DHCP, Reply, length 548, xid 0x76a26648, Flags [none] (0x0000) + Your-IP 192.168.4.2 + Client-Ethernet-Address 74:xx:xx:xx:xx:xx + Vendor-rfc1048 Extensions + Magic Cookie 0x63825363 + DHCP-Message (53), length 1: Offer + Subnet-Mask (1), length 4: 255.255.255.0 + Lease-Time (51), length 4: 7200 + Server-ID (54), length 4: 192.168.4.1 + Default-Gateway (3), length 4: 192.168.4.1 + Domain-Name-Server (6), length 4: 192.168.4.1 + BR (28), length 4: 192.168.4.255 + MTU (26), length 2: 1500 + URL (114), length 18: "http://192.168.4.1" + Router-Discovery (31), length 1: N + Vendor-Option (43), length 6: 1.4.0.0.0.2 +``` diff --git a/firmware/components/dns_server/CMakeLists.txt b/firmware/components/dns_server/CMakeLists.txt new file mode 100644 index 0000000..4230585 --- /dev/null +++ b/firmware/components/dns_server/CMakeLists.txt @@ -0,0 +1,3 @@ +idf_component_register(SRCS dns_server.c + INCLUDE_DIRS include + PRIV_REQUIRES esp_netif) diff --git a/firmware/components/dns_server/dns_server.c b/firmware/components/dns_server/dns_server.c new file mode 100644 index 0000000..a5a0221 --- /dev/null +++ b/firmware/components/dns_server/dns_server.c @@ -0,0 +1,290 @@ +/* + * SPDX-FileCopyrightText: 2021-2025 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ + +#include +#include + +#include "esp_log.h" +#include "esp_system.h" +#include "esp_check.h" +#include "esp_netif.h" + +#include "lwip/err.h" +#include "lwip/sockets.h" +#include "lwip/sys.h" +#include "lwip/netdb.h" +#include "dns_server.h" + +#define DNS_PORT (53) +#define DNS_MAX_LEN (256) + +#define OPCODE_MASK (0x7800) +#define QR_FLAG (1 << 7) +#define QD_TYPE_A (0x0001) +#define ANS_TTL_SEC (300) + +static const char *TAG = "example_dns_redirect_server"; + +// DNS Header Packet +typedef struct __attribute__((__packed__)) +{ + uint16_t id; + uint16_t flags; + uint16_t qd_count; + uint16_t an_count; + uint16_t ns_count; + uint16_t ar_count; +} dns_header_t; + +// DNS Question Packet +typedef struct { + uint16_t type; + uint16_t class; +} dns_question_t; + +// DNS Answer Packet +typedef struct __attribute__((__packed__)) +{ + uint16_t ptr_offset; + uint16_t type; + uint16_t class; + uint32_t ttl; + uint16_t addr_len; + uint32_t ip_addr; +} dns_answer_t; + +// DNS server handle +struct dns_server_handle { + bool started; + TaskHandle_t task; + int num_of_entries; + dns_entry_pair_t entry[]; +}; + +/* + Parse the name from the packet from the DNS name format to a regular .-seperated name + returns the pointer to the next part of the packet +*/ +static char *parse_dns_name(char *raw_name, char *parsed_name, size_t parsed_name_max_len) +{ + + char *label = raw_name; + char *name_itr = parsed_name; + int name_len = 0; + + do { + int sub_name_len = *label; + // (len + 1) since we are adding a '.' + name_len += (sub_name_len + 1); + if (name_len > parsed_name_max_len) { + return NULL; + } + + // Copy the sub name that follows the the label + memcpy(name_itr, label + 1, sub_name_len); + name_itr[sub_name_len] = '.'; + name_itr += (sub_name_len + 1); + label += sub_name_len + 1; + } while (*label != 0); + + // Terminate the final string, replacing the last '.' + parsed_name[name_len - 1] = '\0'; + // Return pointer to first char after the name + return label + 1; +} + +// Parses the DNS request and prepares a DNS response with the IP of the softAP +static int parse_dns_request(char *req, size_t req_len, char *dns_reply, size_t dns_reply_max_len, dns_server_handle_t h) +{ + if (req_len > dns_reply_max_len) { + return -1; + } + + // Prepare the reply + memset(dns_reply, 0, dns_reply_max_len); + memcpy(dns_reply, req, req_len); + + // Endianness of NW packet different from chip + dns_header_t *header = (dns_header_t *)dns_reply; + ESP_LOGD(TAG, "DNS query with header id: 0x%X, flags: 0x%X, qd_count: %d", + ntohs(header->id), ntohs(header->flags), ntohs(header->qd_count)); + + // Not a standard query + if ((header->flags & OPCODE_MASK) != 0) { + return 0; + } + + // Set question response flag + header->flags |= QR_FLAG; + + uint16_t qd_count = ntohs(header->qd_count); + header->an_count = htons(qd_count); + + int reply_len = qd_count * sizeof(dns_answer_t) + req_len; + if (reply_len > dns_reply_max_len) { + return -1; + } + + // Pointer to current answer and question + char *cur_ans_ptr = dns_reply + req_len; + char *cur_qd_ptr = dns_reply + sizeof(dns_header_t); + char name[128]; + + // Respond to all questions based on configured rules + for (int qd_i = 0; qd_i < qd_count; qd_i++) { + char *name_end_ptr = parse_dns_name(cur_qd_ptr, name, sizeof(name)); + if (name_end_ptr == NULL) { + ESP_LOGE(TAG, "Failed to parse DNS question: %s", cur_qd_ptr); + return -1; + } + + dns_question_t *question = (dns_question_t *)(name_end_ptr); + uint16_t qd_type = ntohs(question->type); + uint16_t qd_class = ntohs(question->class); + + ESP_LOGD(TAG, "Received type: %d | Class: %d | Question for: %s", qd_type, qd_class, name); + + if (qd_type == QD_TYPE_A) { + esp_ip4_addr_t ip = { .addr = IPADDR_ANY }; + // Check the configured rules to decide whether to answer this question or not + for (int i = 0; i < h->num_of_entries; ++i) { + // check if the name either corresponds to the entry, or if we should answer to all queries ("*") + if (strcmp(h->entry[i].name, "*") == 0 || strcmp(h->entry[i].name, name) == 0) { + if (h->entry[i].if_key) { + esp_netif_ip_info_t ip_info; + esp_netif_get_ip_info(esp_netif_get_handle_from_ifkey(h->entry[i].if_key), &ip_info); + ip.addr = ip_info.ip.addr; + break; + } else if (h->entry[i].ip.addr != IPADDR_ANY) { + ip.addr = h->entry[i].ip.addr; + break; + } + } + } + if (ip.addr == IPADDR_ANY) { // no rule applies, continue with another question + continue; + } + dns_answer_t *answer = (dns_answer_t *)cur_ans_ptr; + + answer->ptr_offset = htons(0xC000 | (cur_qd_ptr - dns_reply)); + answer->type = htons(qd_type); + answer->class = htons(qd_class); + answer->ttl = htonl(ANS_TTL_SEC); + + ESP_LOGD(TAG, "Answer with PTR offset: 0x%" PRIX16 " and IP 0x%" PRIX32, ntohs(answer->ptr_offset), ip.addr); + + answer->addr_len = htons(sizeof(ip.addr)); + answer->ip_addr = ip.addr; + } + } + return reply_len; +} + +/* + Sets up a socket and listen for DNS queries, + replies to all type A queries with the IP of the softAP +*/ +void dns_server_task(void *pvParameters) +{ + char rx_buffer[128]; + char addr_str[128]; + int addr_family; + int ip_protocol; + dns_server_handle_t handle = pvParameters; + + while (handle->started) { + + struct sockaddr_in dest_addr; + dest_addr.sin_addr.s_addr = htonl(INADDR_ANY); + dest_addr.sin_family = AF_INET; + dest_addr.sin_port = htons(DNS_PORT); + addr_family = AF_INET; + ip_protocol = IPPROTO_IP; + inet_ntoa_r(dest_addr.sin_addr, addr_str, sizeof(addr_str) - 1); + + int sock = socket(addr_family, SOCK_DGRAM, ip_protocol); + if (sock < 0) { + ESP_LOGE(TAG, "Unable to create socket: errno %d", errno); + break; + } + ESP_LOGI(TAG, "Socket created"); + + int err = bind(sock, (struct sockaddr *)&dest_addr, sizeof(dest_addr)); + if (err < 0) { + ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + } + ESP_LOGI(TAG, "Socket bound, port %d", DNS_PORT); + + while (handle->started) { + ESP_LOGI(TAG, "Waiting for data"); + struct sockaddr_in6 source_addr; // Large enough for both IPv4 or IPv6 + socklen_t socklen = sizeof(source_addr); + int len = recvfrom(sock, rx_buffer, sizeof(rx_buffer) - 1, 0, (struct sockaddr *)&source_addr, &socklen); + + // Error occurred during receiving + if (len < 0) { + ESP_LOGE(TAG, "recvfrom failed: errno %d", errno); + close(sock); + break; + } + // Data received + else { + // Get the sender's ip address as string + if (source_addr.sin6_family == PF_INET) { + inet_ntoa_r(((struct sockaddr_in *)&source_addr)->sin_addr.s_addr, addr_str, sizeof(addr_str) - 1); + } else if (source_addr.sin6_family == PF_INET6) { + inet6_ntoa_r(source_addr.sin6_addr, addr_str, sizeof(addr_str) - 1); + } + + // Null-terminate whatever we received and treat like a string... + rx_buffer[len] = 0; + + char reply[DNS_MAX_LEN]; + int reply_len = parse_dns_request(rx_buffer, len, reply, DNS_MAX_LEN, handle); + + ESP_LOGI(TAG, "Received %d bytes from %s | DNS reply with len: %d", len, addr_str, reply_len); + if (reply_len <= 0) { + ESP_LOGE(TAG, "Failed to prepare a DNS reply"); + } else { + int err = sendto(sock, reply, reply_len, 0, (struct sockaddr *)&source_addr, sizeof(source_addr)); + if (err < 0) { + ESP_LOGE(TAG, "Error occurred during sending: errno %d", errno); + break; + } + } + } + } + + if (sock != -1) { + ESP_LOGE(TAG, "Shutting down socket"); + shutdown(sock, 0); + close(sock); + } + } + vTaskDelete(NULL); +} + +dns_server_handle_t start_dns_server(dns_server_config_t *config) +{ + dns_server_handle_t handle = calloc(1, sizeof(struct dns_server_handle) + config->num_of_entries * sizeof(dns_entry_pair_t)); + ESP_RETURN_ON_FALSE(handle, NULL, TAG, "Failed to allocate dns server handle"); + + handle->started = true; + handle->num_of_entries = config->num_of_entries; + memcpy(handle->entry, config->item, config->num_of_entries * sizeof(dns_entry_pair_t)); + + xTaskCreate(dns_server_task, "dns_server", 4096, handle, 5, &handle->task); + return handle; +} + +void stop_dns_server(dns_server_handle_t handle) +{ + if (handle) { + handle->started = false; + vTaskDelete(handle->task); + free(handle); + } +} diff --git a/firmware/components/dns_server/include/dns_server.h b/firmware/components/dns_server/include/dns_server.h new file mode 100644 index 0000000..ae954e9 --- /dev/null +++ b/firmware/components/dns_server/include/dns_server.h @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2021-2023 Espressif Systems (Shanghai) CO LTD + * + * SPDX-License-Identifier: Unlicense OR CC0-1.0 + */ + +#pragma once + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef DNS_SERVER_MAX_ITEMS +#define DNS_SERVER_MAX_ITEMS 1 +#endif + +#define DNS_SERVER_CONFIG_SINGLE(queried_name, netif_key) { \ + .num_of_entries = 1, \ + .item = { { .name = queried_name, .if_key = netif_key } } \ + } + +/** + * @brief Definition of one DNS entry: NAME - IP (or the netif whose IP to answer) + * + * @note Please use string literals (or ensure they are valid during dns_server lifetime) as names, since + * we don't take copies of the config values `name` and `if_key` + */ +typedef struct dns_entry_pair { + const char* name; /** + +#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" + +#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; +} + +void app_main(void) +{ + /* + Turn of warnings from HTTP server as redirecting traffic will yield + lots of invalid requests + */ + 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()); + + // Initialize Wi-Fi including netif with default config + esp_netif_create_default_wifi_ap(); + + // 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); +} diff --git a/firmware/main/root.html b/firmware/main/root.html new file mode 100644 index 0000000..6614e8d --- /dev/null +++ b/firmware/main/root.html @@ -0,0 +1,102 @@ + + + + + + ESPRESSO Setup + + + + +
+

Welcome to ESPRESSO

+

Please connect me to your local wifi network

+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + + +
+
+ + + \ No newline at end of file diff --git a/firmware/pytest_captive_portal.py b/firmware/pytest_captive_portal.py new file mode 100644 index 0000000..60c051a --- /dev/null +++ b/firmware/pytest_captive_portal.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +# +# SPDX-FileCopyrightText: 2021-2025 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +import http.client +import logging +import os +import socket +import sys + +import pexpect +import pytest +from pytest_embedded import Dut +from pytest_embedded_idf.utils import idf_parametrize + +try: + import wifi_tools +except ImportError: + wifi_tools_path = str(os.getenv('IDF_PATH')) + '/examples/provisioning/softap_prov/utils' + if wifi_tools_path and wifi_tools_path not in sys.path: + sys.path.insert(0, wifi_tools_path) + import wifi_tools + + +def test_redirect(ip: str, port: str) -> str: + # Establish HTTP connection + sess = http.client.HTTPConnection(ip + ':' + port, timeout=15) + + uri = '/test' + # GET response + sess.request('GET', url=uri) + resp = sess.getresponse() + resp_hdrs = resp.getheaders() + + if resp.status != 302: + raise RuntimeError('Redirect failed, response status: {}'.format(resp.status)) + + for hdr in resp_hdrs: + if hdr[0] == 'location': + uri = hdr[1] + + print('Redirected to uri: {}'.format(uri)) + + # Close HTTP connection + sess.close() + + return uri + + +def test_captive_page(ip: str, port: str, uri: str) -> bool: + # Establish HTTP connection + sess = http.client.HTTPConnection(ip + ':' + port, timeout=15) + + # GET response + sess.request('GET', url=uri) + resp = sess.getresponse() + + resp_data = resp.read().decode() + search_str = 'Redirect to the captive portal' + + if search_str not in resp_data: + raise RuntimeError('Failed to match {} with data from captive portal: {}'.format(search_str, resp_data)) + + # Close HTTP connection + sess.close() + return True + + +@pytest.mark.wifi_wlan +@pytest.mark.temp_skip_ci(targets=['esp32'], reason='unstable case') +@pytest.mark.xfail(reason='Runner unable to connect to target over WiFi', run=False) +@idf_parametrize('target', ['esp32'], indirect=['target']) +def test_example_captive_portal(dut: Dut) -> None: + # Get binary file + binary_file = os.path.join(dut.app.binary_path, 'captive_portal.bin') + bin_size = os.path.getsize(binary_file) + logging.info('captive_portal_bin_size : {}KB'.format(bin_size // 1024)) + + # Parse IP address of STA + logging.info('Waiting to connect with softAP') + ap_ip = dut.expect(r'Set up softAP with IP: (\d+.\d+.\d+.\d+)', timeout=60)[1] + + wifi_info = dut.expect(r"wifi_init_softap finished. SSID:'(\S+)' password:'(\S+)'", timeout=30) + ssid = wifi_info[1].decode() + password = wifi_info[2].decode() + port = dut.expect(r"(?:[\s\S]*)Starting server on port: '(\d+)'", timeout=30)[1] + + iface = wifi_tools.get_wiface_name() + if iface is None: + raise RuntimeError('Failed to get Wi-Fi interface on host') + print('Interface name : ' + iface) + print('SoftAP SSID : ' + ssid) + print('SoftAP Password : ' + password) + + try: + ctrl = wifi_tools.wpa_cli(iface, reset_on_exit=True) + print('Connecting to DUT SoftAP...') + try: + ip = ctrl.connect(ssid, password) + except RuntimeError as err: + logging.info('error: {}'.format(err)) + try: + got_ip = dut.expect(r'DHCP server assigned IP to a station, IP is: (\d+.\d+.\d+.\d+)', timeout=60)[ + 1 + ].decode() + logging.info('got_ip: {}'.format(got_ip)) + if ip != got_ip: + raise RuntimeError('SoftAP connected to another host! {} != {}'.format(ip, got_ip)) + except pexpect.exceptions.TIMEOUT: + # print what is happening on DUT side + logging.info('in exception pexpect.exceptions.TIMEOUT') + logging.info(dut.read()) + raise + print('Connected to DUT SoftAP') + + host_name = 'www.google.com' + host = socket.gethostbyname(host_name) + print('hostname: {} resolved to: {}'.format(host_name, host)) + if host != ap_ip: + raise RuntimeError("DNS server failed to redirect question to the softAP's IP") + + uri = test_redirect(ap_ip, port) + test_captive_page(ap_ip, port, uri) + finally: + ctrl.reset() diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults new file mode 100644 index 0000000..28720b5 --- /dev/null +++ b/firmware/sdkconfig.defaults @@ -0,0 +1 @@ +CONFIG_LWIP_MAX_SOCKETS=16