Chapter 114: Building Web Interfaces for ESP32

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the components of a web interface (HTML, CSS, JavaScript) and their roles.
  • Serve static web assets (HTML, CSS, JavaScript files, and images) from an ESP32.
  • Implement different methods for storing and serving web assets on the ESP32 (embedding vs. filesystem).
  • Create HTML forms to send user input and commands to the ESP32.
  • Use client-side JavaScript to make asynchronous requests (Fetch API/AJAX) to ESP32 API endpoints for dynamic data updates without page reloads.
  • Manipulate the HTML Document Object Model (DOM) with JavaScript to display real-time data from the ESP32.
  • Apply basic responsive web design principles to improve usability on various screen sizes.
  • Understand how WebSockets can be used for real-time, bidirectional communication between the ESP32 and a web interface.

Introduction

In the previous chapters, we’ve explored how the ESP32 can act as an HTTP server (Chapter 112) and expose RESTful APIs for programmatic interaction (Chapter 113). While APIs are excellent for machine-to-machine communication, human users often benefit from a graphical user interface (GUI). This chapter focuses on empowering your ESP32 to serve fully-fledged web interfaces—interactive pages built with HTML, CSS, and JavaScript—that can be accessed from any standard web browser on the local network.

Imagine controlling your ESP32-based home automation system, viewing live sensor data, or configuring device settings through a clean, responsive web page hosted directly by the microcontroller itself. This eliminates the need for dedicated mobile apps for simple local control or status monitoring, making your ESP32 projects more accessible and user-friendly. We will delve into structuring your web content, serving it efficiently, and making it dynamic and interactive.

Prerequisite Note: This chapter heavily relies on the ESP32 HTTP server concepts from Chapter 112 and the RESTful API implementation techniques from Chapter 113. A foundational understanding of HTML, CSS, and JavaScript will be extremely beneficial, although key concepts will be introduced.

Theory

Core Components of a Web Interface

A modern web interface typically consists of three main technologies:

  1. HTML (HyperText Markup Language): Defines the structure and content of a web page. It uses tags to create elements like headings, paragraphs, forms, buttons, images, and links.
    Example: <h1>Device Dashboard</h1> <p>Temperature: <span id=”temp-value”>25</span>°C</p>
  2. CSS (Cascading Style Sheets): Describes the presentation and styling of HTML elements. It controls aspects like layout, colors, fonts, and responsiveness.
    Example: body { font-family: Arial, sans-serif; } h1 { color: navy; } #temp-value { font-weight: bold; }
  3. JavaScript (JS): Adds interactivity and dynamic behavior to web pages. It can manipulate HTML and CSS, handle user events (like button clicks), communicate with servers asynchronously, and much more.
    Example: document.getElementById(‘myButton’).onclick = function() { alert(‘Button clicked!’); };

When an ESP32 hosts a web interface, it acts as an HTTP server, delivering these HTML, CSS, and JavaScript files (along with any images or other assets) to the client’s web browser. The browser then renders these files to display the interactive page.

ESP32 serving web interface components to a browser:

%%{ init: { 'flowchart': { 'curve': 'basis', 'htmlLabels': true } } }%%
graph TD
    USER[User Action: Navigates to ESP32 IP] --> BROWSER_REQ_HTML{"Browser: Request HTML<br>(e.g., GET /index.html)"};
    
    subgraph ESP32 HTTP Server
        direction LR
        S_RECV_HTML[Receives Request for HTML]
        S_PROC_HTML["Locates/Prepares HTML<br>(Embedded/Filesystem)"]
        S_SEND_HTML[Sends HTML Response]
    end

    subgraph Browser
        direction LR
        B_PARSE_HTML[Parses HTML]
        B_FIND_ASSETS{"Identifies Linked Assets<br>(CSS, JS, Images in HTML)"}
        B_REQ_CSS["Request CSS<br>(e.g., GET /style.css)"]
        B_REQ_JS["Request JS<br>(e.g., GET /script.js)"]
        B_RENDER[Renders Page with Styles & Interactivity]
    end

    BROWSER_REQ_HTML --> S_RECV_HTML;
    S_RECV_HTML --> S_PROC_HTML;
    S_PROC_HTML --> S_SEND_HTML;
    S_SEND_HTML --> B_PARSE_HTML;
    B_PARSE_HTML --> B_FIND_ASSETS;
    
    B_FIND_ASSETS -- CSS File --> B_REQ_CSS;
    B_FIND_ASSETS -- JS File --> B_REQ_JS;

    subgraph ESP32_Asset_Serving [ESP32 HTTP Server - Asset Handling]
        S_RECV_CSS[Receives Request for CSS]
        S_SEND_CSS[Sends CSS Response]
        S_RECV_JS[Receives Request for JS]
        S_SEND_JS[Sends JS Response]
    end
    
    B_REQ_CSS --> S_RECV_CSS;
    S_RECV_CSS --> S_SEND_CSS;
    S_SEND_CSS --> B_RENDER;
    
    B_REQ_JS --> S_RECV_JS;
    S_RECV_JS --> S_SEND_JS;
    S_SEND_JS --> B_RENDER;

    B_RENDER --> DISPLAY[User Sees Interactive Web Page];

    %% Styling
    classDef default fill:#transparent,stroke:#888,stroke-width:1px,color:#333
    classDef userAction fill:#EDE9FE,stroke:#5B21B6,stroke-width:2px,color:#5B21B6
    classDef browserProc fill:#DBEAFE,stroke:#2563EB,stroke-width:1px,color:#1E40AF
    classDef espProc fill:#FEF3C7,stroke:#D97706,stroke-width:1px,color:#92400E
    classDef decision fill:#FEE2E2,stroke:#DC2626,stroke-width:1px,color:#991B1B
    classDef displayNode fill:#D1FAE5,stroke:#059669,stroke-width:2px,color:#065F46

    class USER userAction;
    class BROWSER_REQ_HTML,B_PARSE_HTML,B_FIND_ASSETS,B_REQ_CSS,B_REQ_JS,B_RENDER browserProc;
    class S_RECV_HTML,S_PROC_HTML,S_SEND_HTML,S_RECV_CSS,S_SEND_CSS,S_RECV_JS,S_SEND_JS espProc;
    class DISPLAY displayNode;

Serving Web Assets from ESP32

There are several ways to store and serve web assets (HTML, CSS, JS, images) from your ESP32:

  • Embedding Assets in Firmware as C Strings/Arrays:
    • Method: Convert the content of each web file into a C string literal or a byte array directly in your firmware code. The HTTP server handler then sends this string/array as the response body.
    • Pros: Simple for very small files; no filesystem required; assets are part of the main firmware binary.
    • Cons: Impractical for larger files or many files; tedious to update (requires recompiling firmware for any web content change); increases firmware size.
    • Example (in handler):
C
const char* html_page = "<!DOCTYPE html><html>...</html>";
httpd_resp_send(req, html_page, HTTPD_RESP_USE_STRLEN);
  • Embedding Assets using COMPONENT_EMBED_FILES (or COMPONENT_EMBED_TXTFILES):
    • Method: ESP-IDF’s build system can embed entire files into the firmware. These files become accessible in your C code via special symbols (_binary_filename_ext_start, _binary_filename_ext_end, _binary_filename_ext_size).
    • Pros: Cleaner than C strings for larger embedded files; assets are part of the firmware binary.
    • Cons: Still requires recompiling for updates; increases firmware size.
    • CMakeLists.txt: idf_component_register(... EMBED_FILES "web/index.html" "web/style.css")
    • Handler C Code:
C
extern const unsigned char index_html_start[] asm("_binary_index_html_start");
extern const unsigned char index_html_end[]   asm("_binary_index_html_end");
const size_t index_html_size = index_html_end - index_html_start;
httpd_resp_send(req, (const char*)index_html_start, index_html_size);
  • Serving from a Filesystem (SPIFFS, LittleFS, or SD Card):
    • Method: Store your web assets as actual files in a filesystem partition (like SPIFFS or LittleFS for onboard flash, or FAT on an SD card). The HTTP server handler reads the requested file from the filesystem and streams its content to the client. (Filesystem setup and management are detailed in Volume 6).Pros: Best for managing multiple/larger web files; web content can be updated without recompiling the main firmware (by updating the filesystem image); separates web development from firmware development.Cons: Requires a filesystem to be initialized and mounted; slightly more overhead for file I/O.Handler C Code (Conceptual for SPIFFS):
C
char filepath[128];
snprintf(filepath, sizeof(filepath), "/spiffs%s", req->uri); // Assuming base path /spiffs
FILE* f = fopen(filepath, "r");
if (f == NULL) {
    httpd_resp_send_404(req);
    return ESP_FAIL;
}
// Read file in chunks and send using httpd_resp_send_chunk() or buffer and send
// ... (file reading and sending logic) ...
fclose(f);
  • Tip: For serving files, it’s crucial to set the correct Content-Type HTTP header based on the file extension (e.g., text/html for .html, text/css for .css, application/javascript for .js, image/png for .png).

Handling HTML Forms

HTML forms allow users to submit data to the server. The ESP32 needs URI handlers to process this submitted data.

  • <form> tag attributes:
    • action="/submit-data": The URI on the ESP32 server that will process the form data.
    • method="GET" or method="POST":
      • GET: Form data is appended to the action URI as a query string (e.g., /submit-data?name=esp32&value=10). Suitable for idempotent requests like search or filtering. Data is visible in the URL.
      • POST: Form data is sent in the request body. Suitable for actions that change state on the server (e.g., updating settings, turning on an LED). Data is not visible in the URL. Preferred for most control actions.
  • Input elements: <input type="text" name="username">, <input type="number" name="level">, <input type="radio" name="mode" value="auto">, <input type="checkbox" name="enable_feature">, <input type="submit" value="Submit">. The name attribute is key for identifying data on the server.

ESP32 Handler for Form Data:

  • GET Form: Parse the query string using httpd_req_get_url_query_str() and httpd_query_key_value().
  • POST Form (default application/x-www-form-urlencoded): Read the request body using httpd_req_recv(). The body will be a URL-encoded string (e.g., name=esp32&value=10). You’ll need to parse this string (e.g., using httpd_query_key_value() on the received buffer or manual parsing).
<form> Attribute / Concept Description Example Value(s) ESP32 Handling Considerations
action Specifies the URI on the ESP32 server where form data will be sent for processing. "/submit-data"
"/config-update"
ESP32 must have a URI handler registered for this path and the specified HTTP method.
method Defines the HTTP method to use when submitting the form data. "GET"
"POST"
GET: Data appended to URL (query string). ESP32 parses using httpd_req_get_url_query_str() & httpd_query_key_value(). Idempotent. Visible data.
POST: Data sent in request body. ESP32 reads body with httpd_req_recv(), then parses (e.g., URL-encoded string). Not idempotent. Hidden data. Preferred for state changes.
<input name="..."> The name attribute of input fields is crucial. It acts as the key for the data value sent to the server. <input type="text" name="ssid">
<input type="number" name="brightness">
ESP32 handler uses these names to extract specific values from the query string (GET) or request body (POST). E.g., find value for key “ssid”.
Data Encoding (POST) Default for HTML forms is application/x-www-form-urlencoded. Data is like a query string in the body (e.g., key1=value1&key2=value2). (Implicitly handled by browser) ESP32 handler needs to parse this URL-encoded string from the request body. httpd_query_key_value() can be used on the received buffer.

Dynamic Content in HTML

  • Server-Side Templating (Basic):The ESP32 can dynamically insert values into an HTML template string before sending it. This is useful for displaying current sensor values or device states when the page first loads.
Feature Server-Side Templating (Basic) Client-Side Updates (JavaScript/Fetch API)
Primary Goal Inject dynamic data into HTML before it’s sent to the browser. Update parts of an already loaded HTML page without a full reload.
When Data is Inserted On the ESP32, during the initial page request. In the browser, after the initial page load, triggered by JavaScript.
User Experience Page loads with initial data. Subsequent updates require a full page reload or navigation. More responsive and interactive. Data changes dynamically, feels like an app.
ESP32 Workload Formats HTML string with data (e.g., using snprintf). Serves static HTML initially. Handles API requests from JavaScript, serves JSON data.
Client (Browser) Workload Renders received HTML. Renders initial HTML. Executes JavaScript to fetch data, parse JSON, and manipulate DOM.
Complexity Simpler for basic initial data display. More complex due to JavaScript logic, API calls, and DOM manipulation, but more powerful.
Example Use Case Displaying current sensor value on page load: <p>Temperature: 25.5 C</p> Periodically updating a temperature display, toggling an LED status, submitting a form without page refresh.
Preferred For Very simple pages with static initial data. Modern, dynamic, and interactive web UIs. Most ESP32 web interfaces will benefit from this.
C
// In C handler:
char html_buffer[512];
float temperature = read_temperature_sensor(); // Your function
snprintf(html_buffer, sizeof(html_buffer),
         "<html><body><h1>Temperature: %.1f °C</h1></body></html>",
         temperature);
httpd_resp_send(req, html_buffer, HTTPD_RESP_USE_STRLEN);
  • Client-Side Updates with JavaScript (Preferred for Dynamic UIs):The initial HTML page is served. Then, client-side JavaScript makes asynchronous requests (see below) to API endpoints on the ESP32 to fetch new data and updates specific parts of the HTML page (DOM manipulation) without a full page reload. This creates a much more responsive and modern user experience.

Client-Side JavaScript for Interactivity

JavaScript running in the browser is key to making web interfaces dynamic.

  • DOM Manipulation: The Document Object Model (DOM) is a programming interface for HTML documents. JavaScript can access and modify the DOM to change page content, structure, and style.
    • document.getElementById("myElement"): Gets an element by its ID.
    • element.innerHTML = "New content": Changes the content of an element.
    • element.style.color = "red": Changes an element’s style.
    • document.createElement("p"), element.appendChild(newElement): Creates and adds new elements.
  • Event Handling: JavaScript can respond to user actions.
    • buttonElement.onclick = function() { /* do something */ };
    • inputElement.addEventListener('change', function() { /* handle change */ });
  • Asynchronous Requests (Fetch API / AJAX):This allows JavaScript to communicate with the ESP32 server in the background to send or retrieve data without reloading the entire page. The Fetch API is the modern standard.
JavaScript
// In your script.js, served to the browser:
async function getDeviceStatus() {
    try {
        // Assumes ESP32 has an API endpoint /api/v1/status (from Ch 113)
        const response = await fetch('/api/v1/status', {
            method: 'GET',
            headers: {
                'X-API-Key': 'supersecretkey123' // If your API needs a key
            }
        });
        if (!response.ok) { // Check for HTTP error codes
            console.error('Error fetching status:', response.status, response.statusText);
            document.getElementById('status-display').innerText = 'Error loading status.';
            return;
        }
        const data = await response.json(); // Parse JSON response
        document.getElementById('temp-value').innerText = data.temperatureCelsius;
        document.getElementById('led-state').innerText = data.ledState ? 'ON' : 'OFF';
        // ... update other elements
    } catch (error) {
        console.error('Fetch API error:', error);
        document.getElementById('status-display').innerText = 'Network error.';
    }
}

// Call it initially and then periodically
getDeviceStatus();
setInterval(getDeviceStatus, 5000); // Update every 5 seconds

sequenceDiagram
    actor User
    participant BrowserJS as Browser JavaScript
    participant ESP32_Server as ESP32 HTTP Server/API
    participant ESP32_Resource as ESP32 Resource (Sensor/LED)

    User->>BrowserJS: Interacts with UI (e.g., Clicks Button, Page Load)
    BrowserJS->>+ESP32_Server: fetch('/api/v1/status', { headers: {'X-API-Key': '...'} })
    ESP32_Server->>+ESP32_Resource: Reads sensor data / LED state
    ESP32_Resource-->>-ESP32_Server: Returns data
    ESP32_Server-->>-BrowserJS: HTTP 200 OK (JSON: {"temp": 25, "led": "ON"})
    
    BrowserJS->>BrowserJS: Parses JSON response
    BrowserJS->>BrowserJS: Updates DOM (e.g., document.getElementById('temp').innerText = data.temp)
    BrowserJS->>User: UI Dynamically Updates

    

WebSockets for Real-Time Bidirectional Communication

While Fetch/AJAX is great for client-initiated updates, WebSockets (covered in Chapters 96 & 97 for client/server implementation) provide a persistent, bidirectional communication channel between the client (browser JavaScript) and the ESP32 server.

Feature Fetch API (AJAX) WebSockets
Communication Model Request-Response (Client initiates). Persistent, Full-Duplex (Bidirectional after initial handshake).
Connection New HTTP(S) connection for each request (can be kept alive but fundamentally request-based). Single, long-lived TCP connection upgraded from HTTP.
Data Flow Initiation Primarily client-to-server (client requests data or sends command). Server responds. Client-to-server OR Server-to-client (server can push data anytime).
Overhead HTTP headers for every request/response. Can be higher for frequent small updates. Lower overhead per message after initial handshake (minimal framing).
Latency Can be higher due to connection setup and headers for each request. Lower latency for real-time data exchange once connection is established.
Typical Use Cases on ESP32 UI – Fetching initial page data.
– Submitting forms.
– User-triggered actions (e.g., button click to toggle LED).
– Periodic polling for status updates.
– Streaming real-time sensor data to UI.
– Instant command execution from UI to ESP32.
– Live log viewing.
– Chat-like applications.
ESP32 Server Implementation Standard HTTP request handlers (e.g., for REST API endpoints). Specific WebSocket URI handler (is_websocket = true). Manage client sessions, async sending.
Client-Side JavaScript fetch('/api/endpoint').then(...) const socket = new WebSocket('ws://...'); socket.onmessage = ...; socket.send(...);
  • Use Cases: Real-time sensor data streaming to the UI, instant command execution from UI to ESP32, live log viewing.
  • ESP32 Side: The esp_http_server supports WebSockets. You register a WebSocket URI handler. When a client connects, the server can send messages (httpd_ws_send_frame()) to that client at any time. It also needs to handle incoming WebSocket frames from clients.
  • Client-Side JavaScript:
JavaScript
const socket = new WebSocket('ws://' + window.location.host + '/ws'); // ESP32's WebSocket URI

socket.onopen = function(event) {
    console.log('WebSocket connection opened:', event);
    socket.send('Hello ESP32 from Browser!');
};

socket.onmessage = function(event) {
    console.log('Message from ESP32:', event.data);
    // Assuming event.data is JSON string: const data = JSON.parse(event.data);
    // Update DOM with new data, e.g., document.getElementById('realtime-data').innerText = data.value;
};

socket.onclose = function(event) {
    console.log('WebSocket connection closed:', event);
};

socket.onerror = function(error) {
    console.error('WebSocket error:', error);
};

sequenceDiagram
    participant BrowserJS as Browser JavaScript
    participant ESP32_WS_Server as ESP32 WebSocket Server

    BrowserJS->>+ESP32_WS_Server: Initiates WebSocket Handshake (HTTP GET to /ws)
    ESP32_WS_Server-->>-BrowserJS: Completes Handshake (HTTP 101 Switching Protocols)
    Note over BrowserJS,ESP32_WS_Server: WebSocket Connection Established!

    par ESP32 Pushes Data to Browser
        ESP32_WS_Server->>BrowserJS: Sends Frame (e.g., {"sensor_update": 30.5})
        BrowserJS->>BrowserJS: Receives Message (onmessage event)
        BrowserJS->>BrowserJS: Updates DOM with new data
    and Browser Pushes Data to ESP32
        BrowserJS->>ESP32_WS_Server: Sends Frame (e.g., {"command": "LED_TOGGLE"})
        ESP32_WS_Server->>ESP32_WS_Server: Receives Message (processes command)
        ESP32_WS_Server-->>BrowserJS: (Optional) Sends Ack/Response Frame
    end

Basic Responsive Design

A responsive web interface adapts its layout to different screen sizes (desktops, tablets, phones).

  • Viewport Meta Tag: Essential for mobile browsers.<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>
  • CSS Media Queries: Apply different styles based on screen width.
CSS
/* Default styles */
.container { width: 80%; margin: auto; }
/* Styles for screens smaller than 600px */
@media screen and (max-width: 600px) {
    .container { width: 100%; }
    /* Adjust font sizes, hide elements, stack elements vertically, etc. */
}
  • Flexible Layouts (Flexbox/Grid): CSS Flexbox and Grid make it easier to create layouts that adapt.
  • Relative Units: Use percentages (%), em, rem, vw, vh for sizes and spacing where appropriate, rather than fixed pixels.

Practical Examples

These examples build upon the HTTP server setup from Chapter 112 and assume Wi-Fi is connected.

Project Structure for Web Assets:

It’s good practice to keep web files in a subdirectory, e.g., main/web/.

my_web_interface_project/
├── main/
│   ├── CMakeLists.txt
│   ├── main.c
│   └── web/  <-- Your HTML, CSS, JS files here
│       ├── index.html
│       ├── style.css
│       └── script.js
├── CMakeLists.txt
└── sdkconfig

Embedding Web Assets in main/CMakeLists.txt:

Plaintext
# In your main/CMakeLists.txt
# ... other configurations ...
set(WEB_FILES
    "web/index.html"
    "web/style.css"
    "web/script.js"
    # Add other assets like images here: "web/logo.png"
)
# Use EMBED_TXTFILES for text files (HTML, CSS, JS)
# Use EMBED_FILES for binary files (images)
idf_component_register(SRCS "main.c"
                    INCLUDE_DIRS "."
                    EMBED_TXTFILES ${WEB_FILES}) # Or use EMBED_FILES if some are binary

This makes index.html available as _binary_index_html_start etc.

Generic File Serving Handler (Simplified):

This handler attempts to serve files embedded using COMPONENT_EMBED_TXTFILES.

C
// In main.c
// ... (includes, Wi-Fi setup, server start/stop from Ch 112) ...

// Helper to determine content type from URI
static const char* get_content_type_from_uri(const char *uri) {
    if (strstr(uri, ".html")) return "text/html";
    else if (strstr(uri, ".css")) return "text/css";
    else if (strstr(uri, ".js")) return "application/javascript";
    else if (strstr(uri, ".png")) return "image/png";
    else if (strstr(uri, ".jpg")) return "image/jpeg";
    else if (strstr(uri, ".ico")) return "image/x-icon";
    return "application/octet-stream"; // Default binary type
}

// Generic handler to serve embedded files
// NOTE: This is a simplified example. For a production system with many files,
// a more robust mapping from URI to embedded symbol would be needed,
// or serving from a filesystem like SPIFFS is preferred.
static esp_err_t common_get_handler(httpd_req_t *req)
{
    ESP_LOGI(TAG, "Serving URI: %s", req->uri);
    // Determine which file to serve based on req->uri
    // This example serves index.html for "/" or "/index.html"
    // and specific files for other URIs.

    extern const unsigned char index_html_start[] asm("_binary_web_index_html_start");
    extern const unsigned char index_html_end[]   asm("_binary_web_index_html_end");
    extern const unsigned char style_css_start[]  asm("_binary_web_style_css_start");
    extern const unsigned char style_css_end[]    asm("_binary_web_style_css_end");
    extern const unsigned char script_js_start[]  asm("_binary_web_script_js_start");
    extern const unsigned char script_js_end[]    asm("_binary_web_script_js_end");

    const unsigned char *file_start = NULL;
    size_t file_size = 0;

    if (strcmp(req->uri, "/") == 0 || strcmp(req->uri, "/index.html") == 0) {
        file_start = index_html_start;
        file_size = index_html_end - index_html_start;
    } else if (strcmp(req->uri, "/style.css") == 0) {
        file_start = style_css_start;
        file_size = style_css_end - style_css_start;
    } else if (strcmp(req->uri, "/script.js") == 0) {
        file_start = script_js_start;
        file_size = script_js_end - script_js_start;
    } else {
        ESP_LOGE(TAG, "File not found for URI: %s", req->uri);
        httpd_resp_send_404(req);
        return ESP_FAIL;
    }

    httpd_resp_set_type(req, get_content_type_from_uri(req->uri));
    httpd_resp_send(req, (const char*)file_start, file_size);
    return ESP_OK;
}

static const httpd_uri_t common_uri_get = {
    .uri      = "/*", // Wildcard for all GET requests not handled by more specific handlers
    .method   = HTTP_GET,
    .handler  = common_get_handler,
    .user_ctx = NULL
};

// In start_webserver() or start_https_webserver():
// Register this handler LAST, as "/*" will catch anything not caught before.
// httpd_register_uri_handler(server, &common_uri_get);

Important: The asm("_binary_web_index_html_start") symbol name depends on the path specified in EMBED_TXTFILES. If you put index.html in main/web/, the symbol becomes _binary_web_index_html_start. Dots and slashes in the path are replaced by underscores.

Example 1: Serving a Basic HTML Page with CSS

main/web/index.html:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ESP32 Web Interface</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>Hello from ESP32!</h1>
        <p>This is a simple web page served by an ESP32 microcontroller.</p>
        <p>Current Temperature: <span id="temp-value">--</span> °C</p>
        <p>LED Status: <span id="led-status">Unknown</span></p>
        <button id="toggle-led-btn">Toggle LED</button>
    </div>
    <script src="script.js"></script>
</body>
</html>

main/web/style.css:

CSS
body {
    font-family: Arial, Helvetica, sans-serif;
    margin: 0;
    padding: 20px;
    background-color: #f4f4f4;
    color: #333;
    text-align: center;
}
.container {
    max-width: 600px;
    margin: auto;
    background-color: #fff;
    padding: 20px;
    border-radius: 8px;
    box-shadow: 0 0 10px rgba(0,0,0,0.1);
}
h1 {
    color: #0056b3; /* ESP blue */
}
button {
    background-color: #007bff;
    color: white;
    padding: 10px 15px;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    font-size: 16px;
}
button:hover {
    background-color: #0056b3;
}
#temp-value, #led-status {
    font-weight: bold;
    color: #d9534f;
}

main/web/script.js (Initial, will be expanded):

JavaScript
console.log("ESP32 Web Interface script loaded.");
// Further JS for dynamic content will go here

main.c:

Include the common_get_handler and register common_uri_get in your start_webserver function.

Ensure main/CMakeLists.txt embeds these files.

Build, Flash, Observe:

  1. Build and flash.
  2. Open http://<ESP32_IP_ADDRESS>/ in your browser. You should see the styled HTML page. Check the browser’s developer console (Network tab) to see style.css and script.js being loaded.

Example 2: HTML Form to Control LED (POST) & Dynamic Updates via Fetch API

This builds on Example 1 and the API endpoints from Chapter 113.

Modify main/web/index.html:

HTML
<body>
    <div class="container">
        <h1>ESP32 Control Panel</h1>
        <p>Device Name: <span id="device-name">--</span></p>
        <p>Current Temperature: <span id="temp-value">--</span> °C</p>
        <div class="led-control">
            <p>LED Status: <span id="led-status">Unknown</span></p>
            <button id="led-on-btn">Turn ON</button>
            <button id="led-off-btn">Turn OFF</button>
        </div>
         <form id="deviceNameForm" style="margin-top: 20px;">
            <label for="newDeviceName">Set Device Name:</label>
            <input type="text" id="newDeviceName" name="newDeviceName" required>
            <button type="submit">Update Name</button>
        </form>
        <p id="response-message" style="margin-top: 10px;"></p>
    </div>
    <script src="script.js"></script>
</body>
</html>

main/web/script.js (Expanded):

JavaScript
const API_KEY = 'supersecretkey123'; // Same as in ESP32 C code

// DOM Elements
const deviceNameEl = document.getElementById('device-name');
const tempValueEl = document.getElementById('temp-value');
const ledStatusEl = document.getElementById('led-status');
const ledOnBtn = document.getElementById('led-on-btn');
const ledOffBtn = document.getElementById('led-off-btn');
const deviceNameForm = document.getElementById('deviceNameForm');
const newDeviceNameInput = document.getElementById('newDeviceName');
const responseMessageEl = document.getElementById('response-message');

// Fetch initial status
async function fetchDeviceStatus() {
    try {
        const response = await fetch('/api/v1/status', {
            headers: { 'X-API-Key': API_KEY }
        });
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        const data = await response.json();
        deviceNameEl.textContent = data.deviceName || 'N/A';
        tempValueEl.textContent = data.temperatureCelsius !== undefined ? data.temperatureCelsius.toFixed(1) : '--';
        ledStatusEl.textContent = data.ledState ? 'ON' : 'OFF';
        ledStatusEl.style.color = data.ledState ? 'green' : 'red';
    } catch (error) {
        console.error("Error fetching status:", error);
        tempValueEl.textContent = 'Error';
        ledStatusEl.textContent = 'Error';
    }
}

// Control LED
async function controlLed(state) { // state is "on" or "off"
    try {
        const response = await fetch('/api/v1/led', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                'X-API-Key': API_KEY
            },
            body: JSON.stringify({ state: state })
        });
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        const data = await response.json();
        console.log("LED control response:", data);
        fetchDeviceStatus(); // Refresh status after action
        responseMessageEl.textContent = data.message || `LED turned ${state}`;
        responseMessageEl.style.color = 'green';
    } catch (error) {
        console.error("Error controlling LED:", error);
        responseMessageEl.textContent = `Error controlling LED.`;
        responseMessageEl.style.color = 'red';
    }
}

// Update Device Name
async function updateDeviceName(newName) {
    try {
        const response = await fetch('/api/v1/settings/devicename', {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
                'X-API-Key': API_KEY
            },
            body: JSON.stringify({ name: newName })
        });
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        const data = await response.json();
        console.log("Update name response:", data);
        fetchDeviceStatus(); // Refresh status
        responseMessageEl.textContent = data.message || `Device name updated to ${data.newName}.`;
        responseMessageEl.style.color = 'green';
        newDeviceNameInput.value = ''; // Clear input
    } catch (error) {
        console.error("Error updating device name:", error);
        responseMessageEl.textContent = `Error updating name.`;
        responseMessageEl.style.color = 'red';
    }
}

// Event Listeners
if(ledOnBtn) ledOnBtn.addEventListener('click', () => controlLed('on'));
if(ledOffBtn) ledOffBtn.addEventListener('click', () => controlLed('off'));
if(deviceNameForm) deviceNameForm.addEventListener('submit', (event) => {
    event.preventDefault(); // Prevent default form submission
    const newName = newDeviceNameInput.value.trim();
    if (newName) {
        updateDeviceName(newName);
    } else {
        responseMessageEl.textContent = 'Device name cannot be empty.';
        responseMessageEl.style.color = 'orange';
    }
});

// Initial load and periodic update
fetchDeviceStatus();
setInterval(fetchDeviceStatus, 5000); // Update status every 5 seconds

console.log("ESP32 Web Interface script fully initialized.");

main.c:

Ensure you have the API handlers from Chapter 113 (status_get_handler, led_post_handler, device_name_put_handler, device_name_get_handler) registered with your HTTP server.

Ensure the common_get_handler is also registered to serve index.html, style.css, and script.js.

Make sure LED_GPIO is configured and API_KEY matches.

Build, Flash, Observe:

  1. Build and flash.
  2. Open http://<ESP32_IP_ADDRESS>/ in your browser.
  3. The page should load, display initial (or fetched) status.
  4. Clicking “Turn ON”/”Turn OFF” should control the LED and update the status on the page.
  5. Entering a new name in the form and submitting should update the device name (verify with status or by checking the /api/v1/settings/devicename GET endpoint if you implemented it).

Example 3: Basic WebSocket Integration for Real-Time Updates

This example shows how to push a simple counter from ESP32 to the web page via WebSockets.

main/web/index.html (Add a placeholder):

HTML
<p>Real-time Counter: <span id="ws-counter">--</span></p>

main/web/script.js (Add WebSocket client logic):

JavaScript
// ... (existing JS code from Example 2) ...
const wsCounterEl = document.getElementById('ws-counter');

function setupWebSocket() {
    const socket = new WebSocket('ws://' + window.location.host + '/ws'); // ESP32's WebSocket URI

    socket.onopen = function(event) {
        console.log('WebSocket connection opened:', event);
        wsCounterEl.textContent = 'Connected';
    };

    socket.onmessage = function(event) {
        console.log('WS Message from ESP32:', event.data);
        try {
            const data = JSON.parse(event.data);
            if (data.counter !== undefined) {
                wsCounterEl.textContent = data.counter;
            }
        } catch (e) {
            wsCounterEl.textContent = event.data; // Display as raw if not JSON
        }
    };

    socket.onclose = function(event) {
        console.log('WebSocket connection closed:', event);
        wsCounterEl.textContent = 'Disconnected';
        // Optional: Try to reconnect after a delay
        setTimeout(setupWebSocket, 3000);
    };

    socket.onerror = function(error) {
        console.error('WebSocket error:', error);
        wsCounterEl.textContent = 'Error';
    };
}

// Call after DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
    // ... (existing initializations like fetchDeviceStatus) ...
    if (wsCounterEl) { // Check if the element exists
         setupWebSocket();
    }
});

main.c (Add WebSocket server handler):

C
// ... (includes, common code, other handlers) ...

// Structure to hold client session data for WebSockets
struct async_resp_arg {
    httpd_handle_t hd;  // Server instance
    int fd;             // Session socket file descriptor
};

// Task to periodically send WebSocket data
static void ws_sender_task(void *pvParameters) {
    struct async_resp_arg *resp_arg = (struct async_resp_arg *)pvParameters;
    httpd_handle_t hd = resp_arg->hd;
    int fd = resp_arg->fd;

    char buf[128];
    int counter = 0;
    httpd_ws_frame_t ws_pkt;
    memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t));
    ws_pkt.type = HTTPD_WS_TYPE_TEXT;
    ws_pkt.final = true; // This is a single-frame message

    ESP_LOGI(TAG, "Starting WS sender task for fd: %d", fd);

    while (1) {
        // Check if client is still connected before sending
        // This is a simplified check; httpd_query_client_fd might be better
        // or checking return value of httpd_ws_send_frame
        if (httpd_is_sess_available(hd, fd)) {
             snprintf(buf, sizeof(buf), "{\"counter\": %d}", counter++);
             ws_pkt.payload = (uint8_t*)buf;
             ws_pkt.len = strlen(buf);

            esp_err_t ret = httpd_ws_send_frame_async(hd, fd, &ws_pkt);
            if (ret != ESP_OK) {
                ESP_LOGE(TAG, "httpd_ws_send_frame_async failed with %d for fd %d", ret, fd);
                // If send fails, client might have disconnected, break loop
                break; 
            }
        } else {
            ESP_LOGW(TAG, "WS client fd %d no longer available. Stopping sender task.", fd);
            break; // Exit task if session is not available
        }
        vTaskDelay(pdMS_TO_TICKS(2000)); // Send every 2 seconds
    }
    // Cleanup when task exits
    ESP_LOGI(TAG, "WS sender task for fd %d finished.", fd);
    free(resp_arg); // Free the context passed to this task
    vTaskDelete(NULL);
}


static esp_err_t websocket_handler(httpd_req_t *req)
{
    if (req->method == HTTP_GET) { // Standard WebSocket handshake
        ESP_LOGI(TAG, "Handshake done, new WS client connection: fd %d", httpd_req_to_sockfd(req));
        // Each client connection gets its own sender task
        struct async_resp_arg *resp_arg = malloc(sizeof(struct async_resp_arg));
        if (resp_arg == NULL) {
            ESP_LOGE(TAG, "Failed to allocate memory for WS async_resp_arg");
            return ESP_FAIL;
        }
        resp_arg->hd = req->handle;
        resp_arg->fd = httpd_req_to_sockfd(req);

        // It's important that the task has a higher priority than the httpd server task
        // or that the httpd server is configured for async send.
        // For simplicity, we create a new task per connection.
        // Consider a task pool or more sophisticated management for many clients.
        if (xTaskCreate(ws_sender_task, "ws_sender", 4096, resp_arg, 5, NULL) != pdPASS) {
            ESP_LOGE(TAG, "Failed to create ws_sender_task");
            free(resp_arg); // Free if task creation fails
        }
        return ESP_OK; // Return ESP_OK to keep connection open for WebSocket
    }

    // Handle incoming WebSocket frames (messages from client)
    httpd_ws_frame_t ws_pkt;
    uint8_t *buf = NULL;
    memset(&ws_pkt, 0, sizeof(httpd_ws_frame_t));
    ws_pkt.type = HTTPD_WS_TYPE_TEXT; // Expecting text

    // First, get the frame info to determine buffer size
    esp_err_t ret = httpd_ws_recv_frame(req, &ws_pkt, 0); // 0 timeout = peek
    if (ret != ESP_OK) {
        ESP_LOGE(TAG, "httpd_ws_recv_frame failed to get frame info: %d", ret);
        return ret;
    }

    ESP_LOGI(TAG, "WS frame len is %d", ws_pkt.len);
    if (ws_pkt.len > 0) {
        buf = calloc(1, ws_pkt.len + 1); // +1 for null terminator
        if (buf == NULL) {
            ESP_LOGE(TAG, "Failed to calloc memory for WS frame");
            return ESP_ERR_NO_MEM;
        }
        ws_pkt.payload = buf;
        // Now, read the actual frame content
        ret = httpd_ws_recv_frame(req, &ws_pkt, ws_pkt.len);
        if (ret != ESP_OK) {
            ESP_LOGE(TAG, "httpd_ws_recv_frame failed to read frame payload: %d", ret);
            free(buf);
            return ret;
        }
        ESP_LOGI(TAG, "Got WS packet with message: %s", ws_pkt.payload);
        // You can process the client's message here
    }

    // Echo the received message back to the client (optional)
    // ret = httpd_ws_send_frame(req, &ws_pkt);
    // if (ret != ESP_OK) {
    //     ESP_LOGE(TAG, "httpd_ws_send_frame failed: %d", ret);
    // }
    free(buf); // Free buffer if allocated
    return ret;
}

static const httpd_uri_t ws_uri = {
    .uri        = "/ws",
    .method     = HTTP_GET, // WebSocket handshake is a GET request
    .handler    = websocket_handler,
    .user_ctx   = NULL,
    .is_websocket = true, // Mark this URI as a WebSocket endpoint
    .handle_ws_control_frames = false // Set to true if you want to handle ping/pong etc.
};

// In start_webserver() or start_https_webserver():
// httpd_register_uri_handler(server, &ws_uri);

Build, Flash, Observe:

  1. Register the ws_uri handler.
  2. Build, flash, and open the web page.
  3. The “Real-time Counter” should start updating every 2 seconds, pushed from the ESP32. Check browser console for WebSocket logs.

Variant Notes

  • Flash Memory: Storing extensive web assets (many HTML, CSS, JS files, large images) directly embedded in firmware will consume significant flash space. For complex UIs, using SPIFFS/LittleFS is highly recommended. This is especially true for variants with less flash (e.g., ESP32-C3 with 4MB flash).
  • RAM:
    • The HTTP server itself, especially with multiple concurrent connections or HTTPS, consumes RAM.
    • Buffering files for sending (if not streaming directly or using httpd_resp_send_chunk) requires RAM.
    • WebSocket connections also have a per-client RAM overhead.
    • Variants like ESP32-S2, ESP32-C3, ESP32-C6, ESP32-H2 have less RAM than ESP32/ESP32-S3, so UIs should be kept simpler, or assets served efficiently. PSRAM on ESP32-S3 can alleviate some pressure.
  • CPU Performance:
    • Serving many requests, especially for dynamic content generation or complex API calls triggered by the UI, can load the CPU.
    • JavaScript execution happens in the client’s browser, not on the ESP32. However, the ESP32 needs to efficiently serve the JS files and handle API requests from the JS.
    • Single-core variants (S2, C3, C6, H2) will feel the load more than dual-core variants (ESP32, S3) if the web interface is very active with many background requests.

Common Mistakes & Troubleshooting Tips

Mistake / Issue Symptom(s) Troubleshooting / Solution
Asset Loading Errors (404 Not Found for CSS/JS/Images) HTML page loads, but styling is missing, interactivity fails, images don’t appear. Browser console shows 404 errors for these assets. – Verify paths in HTML (<link href="style.css">, <script src="script.js">) match URIs handled by ESP32.
Embedded: Check COMPONENT_EMBED_FILES paths in CMakeLists.txt and corresponding _binary_..._start symbols in C code (. and / in path become _ in symbol).
Filesystem (SPIFFS/LittleFS): Ensure files are uploaded correctly and paths in C code (e.g., /spiffs/style.css) are exact.
– Ensure generic file handler (/*) is registered last or specific handlers exist.
Incorrect MIME Types Assets load (200 OK) but browser doesn’t interpret them correctly (CSS not applied, JS not run, images broken). – ESP32 server must set correct Content-Type header for each asset type.
– E.g., text/html for .html, text/css for .css, application/javascript for .js, image/png for .png.
– Use a helper function like get_content_type_from_uri().
JavaScript Not Working / Console Errors Page interactivity broken, dynamic updates fail. Open browser developer console (F12) -> “Console” & “Network” tabs.
– Look for JS syntax errors, runtime errors (e.g., “undefined is not a function”).
– Check Fetch API errors: network issues, HTTP errors from ESP32 (401, 404, 500), incorrect API key.
– Use console.log() liberally in JS for debugging.
– Verify JS files are loaded (Network tab, 200 OK).
HTML Form Submissions Fail Clicking submit does nothing, or data doesn’t reach ESP32, or ESP32 returns an error. – Check <form action="..." method="..."> in HTML. URI and method must match an ESP32 handler.
– Verify name attributes on <input> elements are correct.
– ESP32: Correctly parse form data (query string for GET, request body for POST – usually URL-encoded).
– If using JS to submit form (e.g., via Fetch), ensure event.preventDefault() is called and data is correctly formatted in Fetch body.
WebSocket Connection Issues Connection fails, messages not sent/received. Browser console shows WebSocket errors. – JS: WebSocket URI (new WebSocket('ws://host/path')) must match ESP32’s registered WebSocket handler path.
– ESP32: Handler URI must have .is_websocket = true.
– Check ESP32 serial logs for handshake errors or issues in send/receive logic.
– Ensure ws_sender_task (if used) is created correctly and has resources (stack, priority). Handle client disconnections gracefully in sender task.
Responsive Design Not Working Page looks fine on desktop but poorly on mobile (e.g., too wide, text too small). – Ensure <meta name="viewport" content="width=device-width, initial-scale=1.0"> is in HTML <head>.
– Use CSS media queries (@media screen and (max-width: 600px) { ... }) to apply mobile-specific styles.
– Use relative units (%, em, vw) and flexible layouts (Flexbox/Grid) where appropriate.

Exercises

  1. Create a Wi-Fi Configuration Page:
    • Build an HTML page with a form to input Wi-Fi SSID and Password.
    • When submitted, the ESP32 should receive this data (POST), attempt to connect to the new Wi-Fi, and store the credentials in NVS for future boots.
    • Provide feedback on the web page about the connection attempt (e.g., “Connecting…”, “Success!”, “Failed!”).
  2. Interactive Device Settings Page:
    • Create a page that displays current settings (e.g., device name, update interval for a sensor) read from the ESP32 via a GET API.
    • Include input fields to change these settings.
    • Use JavaScript and the Fetch API to send PUT requests to update these settings on the ESP32. The ESP32 should save these settings (e.g., to NVS or just in RAM for the exercise).
  3. Responsive Sensor Dashboard:
    • Create an HTML page to display readings from 2-3 mock sensors (e.g., temperature, humidity, light level).
    • Use JavaScript to periodically fetch these values from ESP32 API endpoints.
    • Style the page using CSS to be responsive, so it looks good on both desktop and mobile browsers (use flexbox/grid and media queries).
  4. File Upload Interface (Advanced):
    • Research how to handle file uploads with esp_http_server (it supports multipart/form-data).
    • Create an HTML form with <input type="file">.
    • Implement an ESP32 handler to receive a small text file and print its content to the serial monitor (or save to SPIFFS). This is more complex due to parsing multipart data.
  5. Integrate a Charting Library:
    • Choose a simple client-side JavaScript charting library (e.g., Chart.js – you’d need to serve its JS file or link to a CDN if ESP32 has internet access, though serving locally is preferred for ESP32 interfaces).
    • Create a web page that fetches time-series data (e.g., temperature readings over the last minute) from an ESP32 API endpoint.
    • Use the charting library to display this data as a line graph, updating dynamically.

Summary

  • Web interfaces for ESP32 are built using HTML (structure), CSS (styling), and JavaScript (interactivity).
  • Assets can be embedded in firmware or served from a filesystem (SPIFFS/LittleFS recommended for larger projects).
  • The esp_http_server component serves these assets and handles incoming requests from the browser.
  • HTML forms are used for user input, processed by ESP32 URI handlers (GET or POST).
  • Client-side JavaScript, using the Fetch API, enables dynamic updates of page content by communicating with ESP32 REST APIs without full page reloads.
  • The DOM allows JavaScript to modify HTML content and style in real-time.
  • WebSockets offer a persistent, bidirectional channel for real-time data exchange between the ESP32 and the web UI.
  • Basic responsive design techniques (viewport meta tag, media queries) improve usability across devices.
  • Careful management of ESP32 resources (flash, RAM, CPU) is essential when serving web interfaces.

Further Reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top