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).
This commit is contained in:
2026-07-18 10:20:25 -04:00
commit cb50c99c98
12 changed files with 986 additions and 0 deletions
+20
View File
@@ -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
+8
View File
@@ -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)
+129
View File
@@ -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 <chip_name>`.
### 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:<ba-add>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
```
@@ -0,0 +1,3 @@
idf_component_register(SRCS dns_server.c
INCLUDE_DIRS include
PRIV_REQUIRES esp_netif)
+290
View File
@@ -0,0 +1,290 @@
/*
* SPDX-FileCopyrightText: 2021-2025 Espressif Systems (Shanghai) CO LTD
*
* SPDX-License-Identifier: Unlicense OR CC0-1.0
*/
#include <sys/param.h>
#include <inttypes.h>
#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);
}
}
@@ -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; /**<! Exact match of the name field of the DNS query to answer */
const char* if_key; /**<! Use this network interface IP to answer, only if NULL, use the static IP below */
esp_ip4_addr_t ip; /**<! Constant IP address to answer this query, if "if_key==NULL" */
} dns_entry_pair_t;
/**
* @brief DNS server config struct defining the rules for answering DNS (A type) queries
*
* @note If you want to define more rules, you can set `DNS_SERVER_MAX_ITEMS` before including this header
* Example of using 2 entries with constant IP addresses
* \code{.c}
* #define DNS_SERVER_MAX_ITEMS 2
* #include "dns_server.h"
*
* dns_server_config_t config = {
* .num_of_entries = 2,
* .item = { {.name = "my-esp32.com", .ip = { .addr = ESP_IP4TOADDR( 192, 168, 4, 1) } } ,
* {.name = "my-utils.com", .ip = { .addr = ESP_IP4TOADDR( 192, 168, 4, 100) } } } };
* start_dns_server(&config);
* \endcode
*/
typedef struct dns_server_config {
int num_of_entries; /**<! Number of rules specified in the config struct */
dns_entry_pair_t item[DNS_SERVER_MAX_ITEMS]; /**<! Array of pairs */
} dns_server_config_t;
/**
* @brief DNS server handle
*/
typedef struct dns_server_handle *dns_server_handle_t;
/**
* @brief Set ups and starts a simple DNS server that will respond to all A queries (IPv4)
* based on configured rules, pairs of name and either IPv4 address or a netif ID (to respond by it's IPv4 add)
*
* @param config Configuration structure listing the pairs of (name, IP/netif-id)
* @return dns_server's handle on success, NULL on failure
*/
dns_server_handle_t start_dns_server(dns_server_config_t *config);
/**
* @brief Stops and destroys DNS server's task and structs
* @param handle DNS server's handle to destroy
*/
void stop_dns_server(dns_server_handle_t handle);
#ifdef __cplusplus
}
#endif
+3
View File
@@ -0,0 +1,3 @@
idf_component_register(SRCS main.c
PRIV_REQUIRES esp_event nvs_flash esp_wifi esp_http_server dns_server
EMBED_FILES root.html)
+32
View File
@@ -0,0 +1,32 @@
menu "Example Configuration"
config ESP_WIFI_SSID
string "SoftAP SSID"
default "esp32_ssid"
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.
config ESP_MAX_STA_CONN
int "Maximal STA connections"
default 4
range 1 4 if IDF_TARGET_ESP32C2
range 1 15
help
Maximum number of STAs allowed to connect to the soft-AP (wifi_config_t.ap.max_connection).
Upper bound matches ESP_WIFI_MAX_CONN_NUM in esp_wifi_types_native.h:
1-4 on ESP32-C2, 1-15 on other Wi-Fi chips.
Soft-AP and ESP-NOW share encryption keys; see Wi-Fi driver documentation
if ESP-NOW is enabled.
config ESP_ENABLE_DHCP_CAPTIVEPORTAL
bool "DHCP Captive portal"
default y
help
Enables more modern DHCP-based Option 114 to provide clients with the captive portal URI
endmenu
+195
View File
@@ -0,0 +1,195 @@
/* 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 <sys/param.h>
#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);
}
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESPRESSO Setup</title>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f9;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
text-align: center;
width: 100%;
max-width: 320px;
}
h1 {
color: #333;
font-size: 24px;
margin-top: 0;
}
p {
color: #666;
font-size: 14px;
margin-bottom: 25px;
}
.input-group {
text-align: left;
margin-bottom: 15px;
}
label {
display: block;
font-size: 12px;
font-weight: bold;
color: #333;
margin-bottom: 5px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 10px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
button {
background-color: #007BFF;
color: white;
border: none;
padding: 12px;
width: 100%;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<h1>Welcome to ESPRESSO</h1>
<p>Please connect me to your local wifi network</p>
<!-- The action attribute should point to wherever your server handles the data -->
<form action="/save_config" method="POST">
<div class="input-group">
<label for="ssid">Wifi SSID</label>
<input type="text" id="ssid" name="ssid" placeholder="Network Name" required>
</div>
<div class="input-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" placeholder="Network Password">
</div>
<div class="input-group">
<label for="toolsserver">Tools Server</label>
<input type="text" id="toolsserver" name="toolsserver" placeholder="e.g. 192.168.1.50:8080" required>
</div>
<button type="submit">Submit</button>
</form>
</div>
</body>
</html>
+125
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
CONFIG_LWIP_MAX_SOCKETS=16