SecureLink Browser Command Console
Serve a live ESP32-S3 dashboard with a validated browser command API.
Introduction
30 Second Summary
A control panel feels useful when it shows what a device is doing right now. It becomes trustworthy when unfamiliar instructions leave that device unchanged.
In this project, you will turn an ESP32-S3 into a self-contained browser command console using ESP-IDF v6.1. Its local HTTP API reports live device state while rejecting unsupported commands.
What You'll Build
You will connect your Windows PC to SecureLink-Console, open the board's local page in Microsoft Edge, and watch the uptime climb while accepted and rejected commands appear in the activity log.
By the end of this project, you'll have:
- Direct browser access through a password-protected SoftAP without a router or cloud account.
- Live device status showing the current mode and an uptime counter that updates every two seconds.
- Allow-list validation that accepts only armed or standby. Unsupported input produces a visible rejection without changing the mode.
- Secret Mission: Red-team the command boundary with mixed-case, spaced, unsupported, and oversized input. You will prove that invalid requests cannot change the device mode.
Are there any prerequisites?
The software and services cost $0. You need an ESP32-S3-DevKitC-1-N8R8 v1.1, a data-capable USB 2.0 Standard-A to Micro-B cable, a Windows PC with Visual Studio Code, and basic comfort reading C and JavaScript.
Before We Start
Before any hands-on work, lock in SecureLink's scope: a browser-operated ESP32-S3 console that controls only the device's displayed operating mode. Unknown commands must leave device state unchanged because the command API denies unrecognized input by default.
Install and Verify the Toolchain
SecureLink cannot reach a browser until the ESP32-S3 can receive firmware from your Windows PC. The USB serial connection gives Windows a COM port for that conversation.
ESP-IDF supplies the tools that build firmware for the board. Espressif Installation Manager installs the required toolchain on Windows.
In this step, get ready to:
- Connect the board through its USB-to-UART port.
- Install ESP-IDF v6.1 through Espressif Installation Manager.
- Prove the toolchain works with a version check.
Connect the ESP32-S3 board
The ESP32-S3-DevKitC-1-N8R8 v1.1 has a dedicated USB-to-UART port for power, flashing, and serial communication. A data-capable cable lets Windows detect that connection.
- Inspect the board marking to confirm it says ESP32-S3-DevKitC-1-N8R8.
- Check the board or packaging for revision v1.1.
- Use a data-capable USB 2.0 Standard-A to Micro-B cable for this connection.
- Insert the Micro-B connector into the board's USB-to-UART Port.
- Insert the Standard-A connector into your Windows PC.
Windows assigns the board a serial port whose name starts with COM. Recording that port now gives later flashing commands the correct destination.
- Press the Windows key to open Start.
- Type device manager into the search box.
- Select Device Manager from the search results.
- Locate the serial device that appeared when you connected the board.
- Record its port as your COM port.
No COM Port?
A charging-only cable can power the board without exposing a serial connection. Replace it with a cable that carries data.
Try another USB port if the board still does not appear. Help me diagnose an ESP32-S3 board that does not appear on a Windows COM port.
Good progress. Windows can now see the board that receives your SecureLink firmware.
Install the ESP-IDF toolchain
Espressif Installation Manager coordinates the compiler, Python environment, and supporting tools used by ESP-IDF. Installing through it keeps those pieces tied to the required release.
- Press the Windows key to open Start.
- Type Windows PowerShell.
- Select Windows PowerShell.
Windows may request permission after the installation command starts. Review the request before allowing the installation to continue.
- Install Espressif Installation Manager by running this command:
winget install Espressif.EIM
What Does This Command Do?
The winget command asks Windows Package Manager to download and install Espressif Installation Manager. The package identifier selects Espressif's EIM application.
When the command finishes, the terminal confirms that Espressif Installation Manager has been installed.
Installation Command Failed?
Confirm that the PC has an internet connection. Check that winget is available in the PowerShell window.
Help me troubleshoot the Espressif Installation Manager winget installation.
- Press the Windows key to return to Start.
- Type Espressif Installation Manager.
- Press Enter to open it.
- Click New Installation.
- Click Start Installation.
- Select Easy Installation.
Watch for Prerequisite Checks
EIM checks the Windows prerequisites required by ESP-IDF. This includes Python 3.10 or later.
Follow any prerequisite prompt before continuing. EIM handles the remaining toolchain setup.
- Confirm the latest stable release shown is v6.1.
- Click Start Easy Installation.
Give the Toolchain Time
The download can take several minutes because EIM installs the compiler and supporting tools. A quiet progress screen during this period is normal.
- Wait until EIM confirms that the installation is complete.
That is the largest setup task finished. Your PC now has the toolchain needed to build ESP32-S3 firmware.
Open the IDF Terminal and verify the setup
An IDF Terminal loads the environment for one ESP-IDF installation. Opening it from the v6.1 dashboard ensures that idf.py uses the intended toolchain.
- Return to Espressif Installation Manager from earlier.
- Select Manage Installations.
- Locate the installation for ESP-IDF v6.1.
- Select Open Dashboard.
- Select Open IDF Terminal.
Before you run the check, which ESP-IDF version do you expect this terminal to report?
- Verify the active ESP-IDF installation by running this command:
idf.py --version
What Does This Command Do?
The idf.py frontend manages ESP-IDF projects. The --version option prints the release loaded in the current terminal.
✔️ I see the required version
You should see output identifying ESP-IDF v6.1. Your IDF Terminal is ready to build firmware.
ⓧ I see an older version
The terminal belongs to a different ESP-IDF installation. Use the dashboard for v6.1 to load the required environment.
- Return to Manage Installations in Espressif Installation Manager.
- Locate the ESP-IDF v6.1 installation.
- Select Open Dashboard.
- Select Open IDF Terminal.
- Repeat the version check from above.
ⓧ Command not found
The current terminal has not loaded the ESP-IDF environment. Open the terminal attached to the EIM installation.
- Return to Manage Installations in Espressif Installation Manager.
- Locate the ESP-IDF v6.1 installation.
- Select Open Dashboard.
- Select Open IDF Terminal.
- Repeat the version check from above.
ESP-IDF v6.1 requires project paths without spaces. The next step creates the SecureLink project in a suitable local location.
- Choose a local parent folder whose full path contains no spaces.
- Return to Device Manager from earlier.
- Confirm that your COM port is still visible.
You have now verified both sides of the toolchain. The IDF Terminal identifies ESP-IDF v6.1. Windows still identifies the connected board through its recorded COM port.
Your board is connected and the ESP-IDF toolchain is ready. Next, you will build the first SecureLink dashboard and load it directly from the ESP32-S3.
Serve the First Console
Your toolchain can build firmware for the ESP32-S3. The next challenge is proving the complete path from firmware to a page in your browser.
In this step, you will create an ESP-IDF project that starts a password-protected SoftAP. An embedded HTTP server will serve the first static SecureLink dashboard directly from the board.
In this step, get ready to:
- Create the SecureLink project structure in Visual Studio Code.
- Build the static browser dashboard and embed it in the firmware.
- Flash the ESP32-S3 and open the dashboard through its local Wi-Fi network.
Create the project structure
An ESP-IDF project uses one build file at the top level. Its application source lives in a component folder with a second build file.
- Press the Windows key to open the Windows search bar.
- Type Visual Studio Code into the search bar.
- Press Enter to open Visual Studio Code.
- Click File in the top menu bar.
- Click Open Folder.
- Create a folder named securelink_console inside the local path you chose earlier.
- Select the securelink_console folder.
- Click Select Folder to open the project.
The folder name contains no spaces. This keeps the project path compatible with ESP-IDF.
- Record the full path to this folder as path to securelink_console.
- Create a file named CMakeLists.txt from the Explorer sidebar.
- Create a folder named main from the Explorer sidebar.
You should now see CMakeLists.txt beside the main folder in the Explorer sidebar.
- Paste the root build definition into CMakeLists.txt using this code:
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(securelink_console)
What does this build file do?
- The minimum version line establishes the CMake behavior expected by ESP-IDF.
- The include line loads the ESP-IDF project build system from the installed toolchain.
- The project line names the firmware build securelink_console.
- Save CMakeLists.txt.
- Create a file named CMakeLists.txt inside the main folder.
- Paste the component build definition into main/CMakeLists.txt using this code:
idf_component_register(SRCS "securelink.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "index.html")
How is the component assembled?
- The component compiles securelink.c as the firmware source.
- The current component folder becomes an include location.
- The EMBED_TXTFILES setting places index.html inside the firmware as a null-terminated string.
- Save main/CMakeLists.txt.
- Create a file named securelink.c inside the main folder.
- Create a file named index.html inside the main folder.
Your Explorer sidebar should list main/CMakeLists.txt, main/securelink.c, and main/index.html beneath the project build file.
Is a file in the wrong folder?
Check the indentation in the Explorer sidebar. All three component files must appear beneath main.
Use Help me check my SecureLink ESP-IDF folder structure.
Build the static dashboard
The dashboard starts as static HTML with fixed status values. This gives you a visible browser console before the device exposes live status or working commands.
- Add the document shell to main/index.html using this code:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecureLink Command Console</title>
<style>
</style>
</head>
<body>
<main>
<header>
<div>
<h1>SecureLink Command Console</h1>
<p>Local ESP32-S3 control plane</p>
</div>
<span id="connection" class="badge">Connecting</span>
</header>
</main>
</body>
</html>
What does this HTML create?
- The metadata makes the page scale to the width of the browser window.
- The header names the console and gives the connection badge a stable connection identifier.
- The empty style element gives the next chunks a place to add the dashboard design.
- Save main/index.html.
- Insert the status section below the closing header tag using this code:
<section class="grid" aria-label="Device status">
<article class="card">
<h2>Device</h2>
<p id="device" class="value">Waiting</p>
</article>
<article class="card">
<h2>Mode</h2>
<p id="mode" class="value">standby</p>
</article>
<article class="card">
<h2>Uptime</h2>
<p id="uptime" class="value">0 s</p>
</article>
</section>
How are the status values prepared?
- Each card gives one device value a clear label.
- The device, mode, and uptime identifiers prepare these values for live updates in the next step.
- The visible defaults make the first static dashboard useful before any status request exists.
- Save main/index.html.
- Insert the approved-command section below the status section using this code:
<section class="card">
<h2>Approved Commands</h2>
<div class="controls">
<button type="button" data-command="armed">Arm</button>
<button type="button" class="secondary" data-command="standby">Standby</button>
</div>
<form id="command-form">
<input id="custom-command" aria-label="Custom command" placeholder="Try armed, standby, or an invalid command">
<button type="submit">Send</button>
</form>
</section>
How are commands represented?
- The two quick-action buttons store their command text in data-command attributes.
- The form gives custom input the custom-command identifier.
- The command-form identifier prepares the form for a submit handler in a later step.
- Save main/index.html.
- Insert the activity-log section below the command section using this code:
<section class="card" style="margin-top: 16px">
<h2>Activity Log</h2>
<ol id="log"></ol>
</section>
Why include an empty activity log?
The log element gives later command requests a visible history. Keeping the list empty now makes the static boundary clear.
- Save main/index.html.
- Add the page foundation between the opening and closing style tags using this code:
:root {
color-scheme: dark;
font-family: Inter, Segoe UI, sans-serif;
background: #07111f;
color: #e8f1ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, #12365c 0, transparent 35%),
#07111f;
}
main {
width: min(960px, 92vw);
margin: 0 auto;
padding: 40px 0 64px;
}
What sets the visual foundation?
- The root styles establish the dark color palette and page typography.
- The body combines a dark base with a blue radial highlight.
- The main container limits the content width while leaving space around the dashboard.
- Save main/index.html.
- Add the header and connection-badge styles below the main selector using this code:
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
h1 { margin: 0; font-size: clamp(1.7rem, 4vw, 2.7rem); }
h2 { margin-top: 0; font-size: 1rem; color: #9fb3cc; }
.badge {
border: 1px solid #35506f;
border-radius: 999px;
padding: 8px 12px;
color: #ffcb6b;
background: #0c1b2d;
}
.badge.online {
color: #72f1b8;
border-color: #287c62;
}
How does the header communicate state?
The header places the console name beside a pill-shaped connection badge. The separate online class provides a future success state without changing the HTML structure.
- Save main/index.html.
- Add the status-grid and card styles below the badge styles using this code:
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.card {
background: rgba(12, 27, 45, 0.92);
border: 1px solid #203a57;
border-radius: 16px;
padding: 20px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.25);
}
.value {
margin: 0;
font-size: 1.55rem;
font-weight: 700;
color: #7dd3fc;
}
How do the cards stay responsive?
The grid creates as many cards as the available width can support. Each card keeps its status value prominent through larger blue text.
- Save main/index.html.
- Add the command-control styles below the value selector using this code:
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button, input {
border-radius: 10px;
border: 1px solid #35506f;
padding: 11px 14px;
font: inherit;
}
button {
cursor: pointer;
color: #07111f;
background: #7dd3fc;
font-weight: 700;
}
button.secondary {
color: #e8f1ff;
background: #203a57;
}
How are the controls distinguished?
Shared spacing makes buttons and input fields feel like one control system. The secondary class gives the Standby action a quieter visual weight.
- Save main/index.html.
- Add the form and activity-log styles below the button styles using this code:
form {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
margin-top: 14px;
}
input {
min-width: 0;
color: #e8f1ff;
background: #07111f;
}
#log {
min-height: 160px;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding-left: 20px;
color: #b9c9dc;
}
#log li { margin-bottom: 8px; }
@media (max-width: 560px) {
header { align-items: flex-start; flex-direction: column; }
form { grid-template-columns: 1fr; }
}
What finishes the responsive layout?
- The form gives most of its width to the custom command field.
- The activity log gains a fixed visible area with vertical scrolling.
- The media rule stacks the header and form on narrow screens.
- Save main/index.html.
- Press the Windows key to open the Windows search bar.
- Type Microsoft Edge into the search bar.
- Press Enter to open Microsoft Edge.
- Press Ctrl+O to choose a local file.
- Select main/index.html from the securelink_console folder.
You should see a dark SecureLink Command Console with three status cards. You should also see command controls and an empty activity log.
Does the local page look incomplete?
Confirm that every style snippet sits between the style tags. Confirm that every dashboard section sits inside the main element.
Use Help me troubleshoot the structure of my static SecureLink dashboard.
Start the SoftAP and serve the page
The browser page becomes part of the firmware through its generated embedded symbol. The firmware also initializes NVS before starting the Wi-Fi access point.
Why use SoftAP for the first console?
SoftAP lets the ESP32-S3 create its own local Wi-Fi network. The first console can work without router credentials or a cloud service.
WPA2 protects access to that network. The page still uses local HTTP without application authentication.
- Add the firmware headers and constants to the top of main/securelink.c using this code:
#include <stdio.h>
#include <string.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#define SECURELINK_WIFI_SSID "SecureLink-Console"
#define SECURELINK_WIFI_PASSWORD "securelink-demo"
#define SECURELINK_WIFI_CHANNEL 1
#define SECURELINK_MAX_CONNECTIONS 4
static const char *TAG = "securelink";
extern const uint8_t index_html_start[] asm("_binary_index_html_start");
What does the firmware depend on?
- The ESP-IDF headers provide the event loop, network interface, Wi-Fi, HTTP server, logging, and NVS APIs.
- The four constants define the SoftAP name, password, channel, and client limit.
- The embedded symbol points to the contents of main/index.html inside the firmware image.
- Add the NVS initialization function below the embedded symbol using this code:
static void initialize_nvs(void)
{
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
result = nvs_flash_init();
}
ESP_ERROR_CHECK(result);
}
Why initialize NVS?
The Wi-Fi stack depends on non-volatile storage. The recovery branch erases incompatible NVS data before trying initialization again.
- Add the SoftAP setup function below initialize_nvs using this code:
static void start_softap(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap();
wifi_init_config_t wifi_init_config = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&wifi_init_config));
wifi_config_t wifi_config = {
.ap = {
.ssid = SECURELINK_WIFI_SSID,
.ssid_len = strlen(SECURELINK_WIFI_SSID),
.channel = SECURELINK_WIFI_CHANNEL,
.password = SECURELINK_WIFI_PASSWORD,
.max_connection = SECURELINK_MAX_CONNECTIONS,
.authmode = WIFI_AUTH_WPA2_PSK,
.pmf_cfg = {
.required = true,
},
},
};
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());
}
How is the local network configured?
- The network interface and event loop prepare ESP-IDF networking.
- The Wi-Fi configuration applies WPA2-PSK with protected management frames required.
- The access point uses channel 1 and permits up to 4 connected clients.
- Insert the runtime IP lookup above the closing brace of start_softap using this code:
esp_netif_ip_info_t ip_info;
ESP_ERROR_CHECK(esp_netif_get_ip_info(ap_netif, &ip_info));
ESP_LOGI(TAG, "Wi-Fi ready: %s", SECURELINK_WIFI_SSID);
ESP_LOGI(TAG, "Open http://" IPSTR, IP2STR(&ip_info.ip));
Why print the runtime address?
The board reads the IP assigned to its SoftAP interface. The log turns that address into the local URL you can open from Windows.
- Add the root request handler below start_softap using this code:
static esp_err_t root_get_handler(httpd_req_t *request)
{
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(
request,
(const char *)index_html_start,
HTTPD_RESP_USE_STRLEN
);
}
How does the browser receive the page?
The root handler labels the response as HTML. It sends the embedded page from the generated index_html_start symbol.
- Add the root endpoint definition below root_get_handler using this code:
static const httpd_uri_t root_uri = {
.uri = "/",
.method = HTTP_GET,
.handler = root_get_handler,
.user_ctx = NULL,
};
What contract does this route create?
The route maps an HTTP GET request for / to root_get_handler. This is the first endpoint in the SecureLink API contract.
- Add the server startup function below root_uri using this code:
static httpd_handle_t start_webserver(void)
{
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
if (httpd_start(&server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return NULL;
}
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
return server;
}
How does the server become reachable?
- The default configuration supplies the server's base settings.
- The startup check logs a failure instead of returning an invalid server handle.
- The final registration makes GET / available to connected browsers.
- Add the application entry point below start_webserver using this code:
void app_main(void)
{
initialize_nvs();
start_softap();
if (start_webserver() == NULL) {
ESP_LOGE(TAG, "SecureLink startup failed");
return;
}
ESP_LOGI(TAG, "SecureLink command console is ready");
}
What happens when the board boots?
The entry point initializes storage before starting the SoftAP. It then starts the HTTP server and logs when the console is ready.
- Save main/securelink.c.
- Switch back to the ESP-IDF 6.1 IDF Terminal from earlier.
- Move the terminal into your project folder by running this command:
cd -Path [[PROJECT_PATH="path to securelink_console"]]
The terminal prompt should now end with securelink_console.
- Set the project target to the ESP32-S3 by running:
idf.py set-target esp32s3
ESP-IDF should reconfigure the project for esp32s3 without reporting a configuration failure.
- Build the firmware by running:
idf.py build
The first build is the slowest because ESP-IDF compiles the project components. You should see the build finish successfully and report the firmware image size.
Is the firmware failing to build?
Check that the terminal is inside securelink_console. Confirm that the path contains no spaces.
Compare each filename with main/CMakeLists.txt. Use Help me diagnose my SecureLink ESP-IDF build failure.
Before you flash the board, what do you expect the monitor to reveal about the new Wi-Fi network and its browser address?
- Flash the firmware through your recorded PORT and start the serial monitor by running:
idf.py -p PORT flash monitor
You should see the monitor report Wi-Fi ready: SecureLink-Console. It should then print a local URL beginning with http://.
- Record the printed browser address as http://device-ip-address.
- Open the Windows network panel from the taskbar.
- Select the SecureLink-Console Wi-Fi network.
- Click Connect.
- Enter securelink-demo as the network password.
- Remain connected if Windows indicates that the network does not provide internet access.
- Switch back to Microsoft Edge.
- Enter http://device-ip-address into the address bar.
You should see the dark SecureLink Command Console served directly by the ESP32-S3. The connection badge still reads Connecting because this first page contains static values.
That is the complete device-to-browser path working. Your ESP32-S3 now broadcasts the network and serves its embedded dashboard without a router or cloud service.
Can't open the dashboard?
Confirm that Windows remains connected to SecureLink-Console while the board stays powered. Copy the URL from the current monitor output instead of using a previously saved address.
Use Help me troubleshoot my local SecureLink dashboard connection.
✔️ Awesome, I've got everything!
Great work. Your ESP32-S3 broadcasts SecureLink-Console and serves the static dashboard from its embedded firmware.
Double check that all four project files are saved before moving on.
- Keep the board powered.
- Keep Windows connected to SecureLink-Console.
- Keep the serial monitor available for the next step.
ⓧ I'd like to double check the full code
Compare your four project files with these complete versions.
cmake_minimum_required(VERSION 3.22)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(securelink_console)
idf_component_register(SRCS "securelink.c"
INCLUDE_DIRS "."
EMBED_TXTFILES "index.html")
#include <stdio.h>
#include <string.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#define SECURELINK_WIFI_SSID "SecureLink-Console"
#define SECURELINK_WIFI_PASSWORD "securelink-demo"
#define SECURELINK_WIFI_CHANNEL 1
#define SECURELINK_MAX_CONNECTIONS 4
static const char *TAG = "securelink";
extern const uint8_t index_html_start[] asm("_binary_index_html_start");
static void initialize_nvs(void)
{
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
result = nvs_flash_init();
}
ESP_ERROR_CHECK(result);
}
static void start_softap(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap();
wifi_init_config_t wifi_init_config = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&wifi_init_config));
wifi_config_t wifi_config = {
.ap = {
.ssid = SECURELINK_WIFI_SSID,
.ssid_len = strlen(SECURELINK_WIFI_SSID),
.channel = SECURELINK_WIFI_CHANNEL,
.password = SECURELINK_WIFI_PASSWORD,
.max_connection = SECURELINK_MAX_CONNECTIONS,
.authmode = WIFI_AUTH_WPA2_PSK,
.pmf_cfg = {
.required = true,
},
},
};
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_ERROR_CHECK(esp_netif_get_ip_info(ap_netif, &ip_info));
ESP_LOGI(TAG, "Wi-Fi ready: %s", SECURELINK_WIFI_SSID);
ESP_LOGI(TAG, "Open http://" IPSTR, IP2STR(&ip_info.ip));
}
static esp_err_t root_get_handler(httpd_req_t *request)
{
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(
request,
(const char *)index_html_start,
HTTPD_RESP_USE_STRLEN
);
}
static const httpd_uri_t root_uri = {
.uri = "/",
.method = HTTP_GET,
.handler = root_get_handler,
.user_ctx = NULL,
};
static httpd_handle_t start_webserver(void)
{
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
if (httpd_start(&server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return NULL;
}
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
return server;
}
void app_main(void)
{
initialize_nvs();
start_softap();
if (start_webserver() == NULL) {
ESP_LOGE(TAG, "SecureLink startup failed");
return;
}
ESP_LOGI(TAG, "SecureLink command console is ready");
}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecureLink Command Console</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, Segoe UI, sans-serif;
background: #07111f;
color: #e8f1ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, #12365c 0, transparent 35%),
#07111f;
}
main {
width: min(960px, 92vw);
margin: 0 auto;
padding: 40px 0 64px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
h1 { margin: 0; font-size: clamp(1.7rem, 4vw, 2.7rem); }
h2 { margin-top: 0; font-size: 1rem; color: #9fb3cc; }
.badge {
border: 1px solid #35506f;
border-radius: 999px;
padding: 8px 12px;
color: #ffcb6b;
background: #0c1b2d;
}
.badge.online {
color: #72f1b8;
border-color: #287c62;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.card {
background: rgba(12, 27, 45, 0.92);
border: 1px solid #203a57;
border-radius: 16px;
padding: 20px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.25);
}
.value {
margin: 0;
font-size: 1.55rem;
font-weight: 700;
color: #7dd3fc;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button, input {
border-radius: 10px;
border: 1px solid #35506f;
padding: 11px 14px;
font: inherit;
}
button {
cursor: pointer;
color: #07111f;
background: #7dd3fc;
font-weight: 700;
}
button.secondary {
color: #e8f1ff;
background: #203a57;
}
form {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
margin-top: 14px;
}
input {
min-width: 0;
color: #e8f1ff;
background: #07111f;
}
#log {
min-height: 160px;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding-left: 20px;
color: #b9c9dc;
}
#log li { margin-bottom: 8px; }
@media (max-width: 560px) {
header { align-items: flex-start; flex-direction: column; }
form { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>SecureLink Command Console</h1>
<p>Local ESP32-S3 control plane</p>
</div>
<span id="connection" class="badge">Connecting</span>
</header>
<section class="grid" aria-label="Device status">
<article class="card">
<h2>Device</h2>
<p id="device" class="value">Waiting</p>
</article>
<article class="card">
<h2>Mode</h2>
<p id="mode" class="value">standby</p>
</article>
<article class="card">
<h2>Uptime</h2>
<p id="uptime" class="value">0 s</p>
</article>
</section>
<section class="card">
<h2>Approved Commands</h2>
<div class="controls">
<button type="button" data-command="armed">Arm</button>
<button type="button" class="secondary" data-command="standby">Standby</button>
</div>
<form id="command-form">
<input id="custom-command" aria-label="Custom command" placeholder="Try armed, standby, or an invalid command">
<button type="submit">Send</button>
</form>
</section>
<section class="card" style="margin-top: 16px">
<h2>Activity Log</h2>
<ol id="log"></ol>
</section>
</main>
</body>
</html>
Your first embedded console is live. Next, you will replace its fixed values with device status and expose the missing command path as a visible HTTP failure.
Add Live Status and Expose the Missing Command Path
Your ESP32-S3 already creates its own SoftAP and serves the SecureLink dashboard. The browser can reach the board without a router or cloud service.
The status cards still contain static values, so the page cannot prove that the device is alive. In this step, you will add a live HTTP API for device status before deliberately exposing the missing command route through an HTTP 404 response.
In this step, get ready to:
- Return live device mode and uptime as JSON from the ESP32-S3.
- Refresh the dashboard status cards every two seconds.
- Send browser commands to the missing endpoint to reveal the planned HTTP 404 obstacle.
Add the live status endpoint
The firmware needs a shared mode value plus the ESP Timer API before it can describe its current state. The timer reports elapsed microseconds, which the handler converts into seconds for the dashboard.
- In main/securelink.c, replace the include and static-definition section above index_html_start with this code:
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#define SECURELINK_WIFI_SSID "SecureLink-Console"
#define SECURELINK_WIFI_PASSWORD "securelink-demo"
#define SECURELINK_WIFI_CHANNEL 1
#define SECURELINK_MAX_CONNECTIONS 4
static const char *TAG = "securelink";
static const char *current_mode = "standby";
What Do These Additions Provide?
- The inttypes.h header provides the integer formatting used when the uptime enters the JSON response.
- The esp_timer.h header makes the device uptime function available.
- The current_mode variable gives the API one source of truth for the current device mode.
- Save main/securelink.c.
- Confirm the new definitions compile by running this command in the IDF Terminal from earlier:
idf.py build
The build completes successfully and produces updated firmware files. This confirms that the timer header plus the shared mode value are valid.
Does the build stop near the new includes?
Check that inttypes.h appears before stdio.h. Confirm that esp_timer.h appears between esp_netif.h and esp_wifi.h.
Make sure current_mode is declared once below TAG. Help me diagnose a build failure after adding the ESP Timer include and current_mode variable.
A URI handler receives an HTTP request and returns the response for one route. This handler reads the shared mode plus the device uptime each time the browser requests fresh status.
- In main/securelink.c, add this handler below root_get_handler:
static esp_err_t status_get_handler(httpd_req_t *request)
{
char response[128];
int64_t uptime_seconds = esp_timer_get_time() / 1000000;
snprintf(
response,
sizeof(response),
"{\"device\":\"ESP32-S3\",\"mode\":\"%s\",\"uptime_seconds\":%" PRIi64 "}",
current_mode,
uptime_seconds
);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
What Does This Handler Do?
- The response buffer holds the JSON sent back to the browser.
- The uptime_seconds calculation converts the timer value from microseconds into seconds.
- The response includes the device name plus current_mode plus the latest uptime value.
- The application/json response type tells the browser how to parse the body.
- Save main/securelink.c.
- Confirm the status handler compiles by running this command:
idf.py build
The build completes with the new handler included. The next edit gives that handler an address the browser can request.
Seeing an error around the JSON string?
Compare every escaped quotation mark in the snprintf format string. Check that PRIi64 sits outside the quoted string exactly as shown.
Help me fix the JSON format string in status_get_handler.
The handler exists in the firmware, but the server needs a URI definition before it can route requests to that function. The new route responds only to GET requests at /api/status.
- In main/securelink.c, add this URI definition below root_uri:
static const httpd_uri_t status_uri = {
.uri = "/api/status",
.method = HTTP_GET,
.handler = status_get_handler,
.user_ctx = NULL,
};
How Is the Route Connected?
The status_uri definition maps GET requests for /api/status to status_get_handler. The HTTP server uses this mapping to choose the correct handler.
- Save main/securelink.c.
- Confirm the URI definition compiles by running this command:
idf.py build
The build completes with the status route definition present. One final firmware edit registers that definition with the running server.
- In main/securelink.c, replace the existing start_webserver function with this version:
static httpd_handle_t start_webserver(void)
{
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
if (httpd_start(&server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return NULL;
}
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &status_uri));
return server;
}
What Changed in the Server?
The server now registers root_uri plus status_uri. No command URI is registered, which preserves the planned missing-route test for later in this step.
- Save main/securelink.c.
- Confirm both registered GET routes compile by running this command:
idf.py build
The build completes with the root page plus the live status route registered. Your firmware is ready for the browser refresh loop.
Does status_uri appear undeclared?
Make sure the complete status_uri definition appears above start_webserver. C needs the URI definition before the registration line uses it.
Help me fix the status_uri registration in start_webserver.
✔️ Awesome, I've got everything!
Great. Double check that main/securelink.c is saved and the firmware build completes with both GET routes registered.
ⓧ I'd like to double check the full code
Here is the complete main/securelink.c file for this step:
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#define SECURELINK_WIFI_SSID "SecureLink-Console"
#define SECURELINK_WIFI_PASSWORD "securelink-demo"
#define SECURELINK_WIFI_CHANNEL 1
#define SECURELINK_MAX_CONNECTIONS 4
static const char *TAG = "securelink";
static const char *current_mode = "standby";
extern const uint8_t index_html_start[] asm("_binary_index_html_start");
static void initialize_nvs(void)
{
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
result = nvs_flash_init();
}
ESP_ERROR_CHECK(result);
}
static void start_softap(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap();
wifi_init_config_t wifi_init_config = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&wifi_init_config));
wifi_config_t wifi_config = {
.ap = {
.ssid = SECURELINK_WIFI_SSID,
.ssid_len = strlen(SECURELINK_WIFI_SSID),
.channel = SECURELINK_WIFI_CHANNEL,
.password = SECURELINK_WIFI_PASSWORD,
.max_connection = SECURELINK_MAX_CONNECTIONS,
.authmode = WIFI_AUTH_WPA2_PSK,
.pmf_cfg = {
.required = true,
},
},
};
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_ERROR_CHECK(esp_netif_get_ip_info(ap_netif, &ip_info));
ESP_LOGI(TAG, "Wi-Fi ready: %s", SECURELINK_WIFI_SSID);
ESP_LOGI(TAG, "Open http://" IPSTR, IP2STR(&ip_info.ip));
}
static esp_err_t root_get_handler(httpd_req_t *request)
{
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(
request,
(const char *)index_html_start,
HTTPD_RESP_USE_STRLEN
);
}
static esp_err_t status_get_handler(httpd_req_t *request)
{
char response[128];
int64_t uptime_seconds = esp_timer_get_time() / 1000000;
snprintf(
response,
sizeof(response),
"{\"device\":\"ESP32-S3\",\"mode\":\"%s\",\"uptime_seconds\":%" PRIi64 "}",
current_mode,
uptime_seconds
);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
static const httpd_uri_t root_uri = {
.uri = "/",
.method = HTTP_GET,
.handler = root_get_handler,
.user_ctx = NULL,
};
static const httpd_uri_t status_uri = {
.uri = "/api/status",
.method = HTTP_GET,
.handler = status_get_handler,
.user_ctx = NULL,
};
static httpd_handle_t start_webserver(void)
{
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
if (httpd_start(&server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return NULL;
}
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &status_uri));
return server;
}
void app_main(void)
{
initialize_nvs();
start_softap();
if (start_webserver() == NULL) {
ESP_LOGE(TAG, "SecureLink startup failed");
return;
}
ESP_LOGI(TAG, "SecureLink command console is ready");
}
Refresh the dashboard with live status
The browser needs references to the existing dashboard elements before JavaScript can update them. A small logging helper also gives later command attempts a visible place to report their results.
- In main/index.html, add this script block immediately before </body>:
<script>
const connection = document.querySelector('#connection');
const device = document.querySelector('#device');
const mode = document.querySelector('#mode');
const uptime = document.querySelector('#uptime');
const log = document.querySelector('#log');
const form = document.querySelector('#command-form');
const customCommand = document.querySelector('#custom-command');
function addLog(message) {
const item = document.createElement('li');
item.textContent = `${new Date().toLocaleTimeString()} | ${message}`;
log.prepend(item);
}
</script>
What Does This Script Prepare?
- The seven constants point to the status cards plus the activity log plus the command controls already present in the page.
- The addLog helper creates a timestamped list item and places the newest event at the top.
- Save main/index.html.
- Confirm the embedded page still builds by running this command:
idf.py build
The build completes with the new script embedded in the firmware image. The page can now locate every element that live status needs to update.
Does the build report an HTML embedding problem?
Check that the opening <script> plus closing </script> tags both appear before </body>. Confirm that every selector retains its leading hash character.
Help me inspect the first JavaScript block in main/index.html.
The refresh function requests /api/status without using a cached response. A successful request replaces the static card values with the latest device state.
- In the script block in main/index.html, add this code below addLog and above </script>:
async function refreshStatus() {
try {
const response = await fetch('/api/status', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const status = await response.json();
device.textContent = status.device;
mode.textContent = status.mode;
uptime.textContent = `${status.uptime_seconds} s`;
connection.textContent = 'Online';
connection.classList.add('online');
} catch (error) {
connection.textContent = 'Offline';
connection.classList.remove('online');
}
}
refreshStatus();
setInterval(refreshStatus, 2000);
How Does Live Status Work?
- The fetch request retrieves fresh JSON from /api/status.
- A successful response updates the device plus mode plus uptime cards before marking the connection as online.
- A failed response changes the badge to offline without replacing the last known card values.
- The initial call updates the page immediately, while the interval repeats the request every 2000 milliseconds.
- Save main/index.html.
- Return to the IDF Terminal from earlier.
- Press Ctrl+] if the serial monitor is still running.
- Replace PORT in the command below with the recorded Windows COM port.
Before you flash this version, do you expect the uptime card to remain at zero or start increasing?
- Flash the updated firmware and start the serial monitor by running this command:
idf.py -p PORT flash monitor
The firmware flashes before the board restarts. The serial monitor then prints the local HTTP URL for the SecureLink dashboard.
- Reconnect Windows to SecureLink-Console if the firmware restart dropped the Wi-Fi connection.
- Switch back to Microsoft Edge from earlier.
- Refresh the device HTTP page.
- Watch the uptime card across two refresh cycles.
You will see the badge change to Online plus the Device card change to ESP32-S3. The uptime value increases every two seconds while the Mode card remains at standby.
That is the live path working: the dashboard now proves that it is reading the current state from your ESP32-S3.
Does the dashboard stay offline?
Confirm that Windows is connected to SecureLink-Console. Refresh the exact HTTP URL printed by the serial monitor after the latest restart.
If the page loads but the badge stays offline, compare /api/status in main/index.html with the URI registered in main/securelink.c. Help me troubleshoot why the SecureLink dashboard cannot load live status.
Send commands to the missing route
The live status path now works, so the browser can report the device state before and after a command attempt. The command request intentionally targets /api/command even though the firmware does not register that route.
- In the script block in main/index.html, add this function below refreshStatus:
async function sendCommand(command) {
addLog(`Sending: ${command || '(empty)'}`);
try {
const response = await fetch('/api/command', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: command,
});
const body = await response.text();
if (!response.ok) {
addLog(`Rejected, HTTP ${response.status}: ${body}`);
await refreshStatus();
return;
}
const result = JSON.parse(body);
addLog(`Accepted: mode is ${result.mode}`);
await refreshStatus();
} catch (error) {
addLog(`Request failed: ${error.message}`);
}
}
What Does the Command Request Record?
- The function logs the outgoing command before sending its plain-text body to /api/command.
- A rejected HTTP response records its status plus response body before refreshing the live device state.
- A successful response would record the returned mode before refreshing status.
- A network failure produces a separate request-failed entry.
- Save main/index.html.
- Confirm the command function compiles into the embedded page by running this command:
idf.py build
The build completes with the command request function embedded. The remaining listeners connect that function to the existing controls.
Seeing a JavaScript syntax error during the build?
Check the braces around the fetch options plus the try and catch blocks. Confirm that each template string uses matching backticks.
Help me find a syntax problem in sendCommand.
- In the script block in main/index.html, add these event handlers immediately before refreshStatus();:
document.querySelectorAll('[data-command]').forEach((button) => {
button.addEventListener('click', () => sendCommand(button.dataset.command));
});
form.addEventListener('submit', (event) => {
event.preventDefault();
sendCommand(customCommand.value);
customCommand.value = '';
});
How Do the Controls Trigger Requests?
Each quick-action button sends its own data-command value. The form sends the exact text from the custom command field before clearing that field.
- Save main/index.html.
- Return to the IDF Terminal from earlier.
- Press Ctrl+] if the serial monitor is still running.
- Replace PORT in the command below with the same recorded COM port.
Before you flash and test this version, do you think the Arm request can succeed without a registered command route?
- Flash the completed firmware for this step by running this command:
idf.py -p PORT flash monitor
The updated firmware flashes before the board restarts and prints its local HTTP URL. The server exposes the dashboard plus live status while leaving the command route absent.
- Reconnect Windows to SecureLink-Console if the board restart interrupted the Wi-Fi connection.
- Switch back to the SecureLink page in Microsoft Edge.
- Refresh the page.
Before you click the quick action, what HTTP result do you expect from a route the server does not know?
- Click Arm in the Approved Commands card.
- Compare the new activity-log entry with the Mode card.
You will see a sending entry followed by an entry beginning with Rejected, HTTP 404. The Mode card remains at standby because no firmware handler accepted the command.
This 404 Is the Planned Gap
The browser has successfully reached the ESP32-S3 and asked for /api/command. The HTTP 404 proves that the server has no registered handler for that path.
Live status provides the second half of the proof. The mode remains unchanged after the failed request, so the missing route cannot mutate device state.
- Type unlock into the custom command field.
- Click Send.
- Confirm that another HTTP 404 entry appears in the activity log.
- Confirm that the Mode card still shows standby.
The second request proves that both quick actions and custom text reach the same missing command path. This is the shortfall the next step resolves with a deny-by-default command handler.
✔️ Awesome, I've got everything!
Great. Double check that main/index.html is saved and the dashboard shows live status plus HTTP 404 command entries.
ⓧ I'd like to double check the full code
Here is the complete main/index.html file for this step:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SecureLink Command Console</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, Segoe UI, sans-serif;
background: #07111f;
color: #e8f1ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top right, #12365c 0, transparent 35%),
#07111f;
}
main {
width: min(960px, 92vw);
margin: 0 auto;
padding: 40px 0 64px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
h1 { margin: 0; font-size: clamp(1.7rem, 4vw, 2.7rem); }
h2 { margin-top: 0; font-size: 1rem; color: #9fb3cc; }
.badge {
border: 1px solid #35506f;
border-radius: 999px;
padding: 8px 12px;
color: #ffcb6b;
background: #0c1b2d;
}
.badge.online {
color: #72f1b8;
border-color: #287c62;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.card {
background: rgba(12, 27, 45, 0.92);
border: 1px solid #203a57;
border-radius: 16px;
padding: 20px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.25);
}
.value {
margin: 0;
font-size: 1.55rem;
font-weight: 700;
color: #7dd3fc;
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
button, input {
border-radius: 10px;
border: 1px solid #35506f;
padding: 11px 14px;
font: inherit;
}
button {
cursor: pointer;
color: #07111f;
background: #7dd3fc;
font-weight: 700;
}
button.secondary {
color: #e8f1ff;
background: #203a57;
}
form {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
margin-top: 14px;
}
input {
min-width: 0;
color: #e8f1ff;
background: #07111f;
}
#log {
min-height: 160px;
max-height: 260px;
overflow-y: auto;
margin: 0;
padding-left: 20px;
color: #b9c9dc;
}
#log li { margin-bottom: 8px; }
@media (max-width: 560px) {
header { align-items: flex-start; flex-direction: column; }
form { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<header>
<div>
<h1>SecureLink Command Console</h1>
<p>Local ESP32-S3 control plane</p>
</div>
<span id="connection" class="badge">Connecting</span>
</header>
<section class="grid" aria-label="Device status">
<article class="card">
<h2>Device</h2>
<p id="device" class="value">Waiting</p>
</article>
<article class="card">
<h2>Mode</h2>
<p id="mode" class="value">standby</p>
</article>
<article class="card">
<h2>Uptime</h2>
<p id="uptime" class="value">0 s</p>
</article>
</section>
<section class="card">
<h2>Approved Commands</h2>
<div class="controls">
<button type="button" data-command="armed">Arm</button>
<button type="button" class="secondary" data-command="standby">Standby</button>
</div>
<form id="command-form">
<input id="custom-command" aria-label="Custom command" placeholder="Try armed, standby, or an invalid command">
<button type="submit">Send</button>
</form>
</section>
<section class="card" style="margin-top: 16px">
<h2>Activity Log</h2>
<ol id="log"></ol>
</section>
</main>
<script>
const connection = document.querySelector('#connection');
const device = document.querySelector('#device');
const mode = document.querySelector('#mode');
const uptime = document.querySelector('#uptime');
const log = document.querySelector('#log');
const form = document.querySelector('#command-form');
const customCommand = document.querySelector('#custom-command');
function addLog(message) {
const item = document.createElement('li');
item.textContent = `${new Date().toLocaleTimeString()} | ${message}`;
log.prepend(item);
}
async function refreshStatus() {
try {
const response = await fetch('/api/status', { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const status = await response.json();
device.textContent = status.device;
mode.textContent = status.mode;
uptime.textContent = `${status.uptime_seconds} s`;
connection.textContent = 'Online';
connection.classList.add('online');
} catch (error) {
connection.textContent = 'Offline';
connection.classList.remove('online');
}
}
async function sendCommand(command) {
addLog(`Sending: ${command || '(empty)'}`);
try {
const response = await fetch('/api/command', {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: command,
});
const body = await response.text();
if (!response.ok) {
addLog(`Rejected, HTTP ${response.status}: ${body}`);
await refreshStatus();
return;
}
const result = JSON.parse(body);
addLog(`Accepted: mode is ${result.mode}`);
await refreshStatus();
} catch (error) {
addLog(`Request failed: ${error.message}`);
}
}
document.querySelectorAll('[data-command]').forEach((button) => {
button.addEventListener('click', () => sendCommand(button.dataset.command));
});
form.addEventListener('submit', (event) => {
event.preventDefault();
sendCommand(customCommand.value);
customCommand.value = '';
});
refreshStatus();
setInterval(refreshStatus, 2000);
</script>
</body>
</html>
Your dashboard now reads live device state and exposes the missing command boundary on demand. Next up, you will add the command endpoint while ensuring that only known commands can change the mode.
Accept Only Known Commands
Your live dashboard already reaches the ESP32-S3 through HTTP. The command controls still receive a 404 because the firmware has no command route.
This step closes that gap with allow-list validation. Only exact approved commands can change the device mode.
In this step, get ready to:
- Receive complete command bodies inside a fixed-size buffer.
- Restrict state changes to exact approved commands.
- Register the command endpoint and verify accepted or rejected results.
Receive complete command bodies
A fixed-size buffer creates an input-length boundary before the firmware reads a command. The 32-byte array holds up to 31 command bytes plus the terminating null character.
- Switch back to main/securelink.c in Visual Studio Code.
- Find the closing brace of status_get_handler().
- Start command_post_handler() directly below it with this length check:
static esp_err_t command_post_handler(httpd_req_t *request)
{
char command[32];
if (request->content_len == 0 || request->content_len >= sizeof(command)) {
ESP_LOGW(TAG, "Rejected command with invalid length: %u", (unsigned)request->content_len);
httpd_resp_set_status(request, HTTPD_400);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(
request,
"{\"accepted\":false,\"error\":\"command must be 1 to 31 bytes\"}",
HTTPD_RESP_USE_STRLEN
);
}
What does the length check do?
- The command array reserves 32 bytes for the incoming value.
- The content_len check rejects empty bodies before they reach the command logic.
- A body of 32 bytes or more receives HTTP 400 because it cannot fit beside the terminating null character.
- Continue command_post_handler() directly below the length check with this complete-body read loop:
size_t offset = 0;
while (offset < request->content_len) {
int received = httpd_req_recv(
request,
command + offset,
request->content_len - offset
);
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
if (received <= 0) {
return ESP_FAIL;
}
offset += received;
}
command[offset] = '\0';
Why read in a loop?
A request body can arrive in smaller pieces. The loop advances offset until the firmware has received every byte.
The final null character turns the received bytes into a C string. That gives strcmp() a safe boundary for exact comparison.
Allow only armed and standby
The command boundary now has a complete string to evaluate. Exact comparisons make every value start from a denied state unless it matches an approved command.
- Finish command_post_handler() by placing this logic directly below command[offset] = '\0';:
if (strcmp(command, "armed") == 0 || strcmp(command, "standby") == 0) {
current_mode = strcmp(command, "armed") == 0 ? "armed" : "standby";
char response[96];
snprintf(
response,
sizeof(response),
"{\"accepted\":true,\"mode\":\"%s\"}",
current_mode
);
ESP_LOGI(TAG, "Accepted command: %s", command);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
ESP_LOGW(TAG, "Rejected unsupported command");
httpd_resp_set_status(request, HTTPD_400);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(
request,
"{\"accepted\":false,\"error\":\"unsupported command\"}",
HTTPD_RESP_USE_STRLEN
);
}
How does the allow-list work?
- The first comparison accepts only the exact strings armed or standby.
- An accepted value updates current_mode before the handler returns a successful JSON response.
- Every unsupported value receives HTTP 400 without reaching the state update.
- Save main/securelink.c.
Before you compile, do you expect the new handler to pass the build before any route points to it?
- Switch back to the ESP-IDF Terminal from earlier.
- Build the completed handler by running:
idf.py build
What does this build prove?
The build compiles the new handler against ESP-IDF v6.1. A successful result confirms that the fixed buffer, receive loop, comparisons, and responses are valid firmware code.
You should see the build finish without compiler errors. The handler is ready to be connected to the HTTP server.
Seeing a compiler error?
Check that the length check appears first inside command_post_handler(). Confirm that the receive loop follows it inside the same function.
Compare every brace near the final unsupported-command response. One missing brace can make the compiler report errors on later lines.
Help me fix the command handler build error.
Register and test the command endpoint
A URI descriptor connects the browser's POST request to the new handler. Registering that descriptor replaces the planned 404 with the validated command boundary.
- Place the command URI descriptor directly below status_uri in main/securelink.c using this code:
static const httpd_uri_t command_uri = {
.uri = "/api/command",
.method = HTTP_POST,
.handler = command_post_handler,
.user_ctx = NULL,
};
What does this descriptor connect?
The descriptor matches POST requests sent to /api/command. Matching requests are passed to command_post_handler().
- In start_webserver(), find this existing route list:
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &status_uri));
return server;
What is registered now?
The current list registers the dashboard and live-status routes. It returns the server handle before registering a command route.
- Replace the existing route list with this completed version:
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &status_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &command_uri));
return server;
What changed in the server?
The server now registers all three routes before returning its handle. POST requests to /api/command can now reach the allow-list.
- Save main/securelink.c.
Before you flash the firmware, what should replace the previous 404 when the dashboard sends an approved command?
- Flash the completed firmware and start the serial monitor by running:
idf.py -p PORT flash monitor
What does this command do?
Replace PORT with the board's Windows COM port. ESP-IDF rebuilds the project before writing the completed firmware to the board.
The monitor then shows the board's startup logs and local HTTP URL. Press Ctrl+] when you want to exit the monitor.
- Wait for the monitor to print the local HTTP URL.
- Reconnect Windows to SecureLink-Console if the firmware restart disconnected the network.
- Return to Microsoft Edge from earlier.
- Refresh the SecureLink dashboard.
Unable to flash the board?
Confirm the data cable remains connected to the board's USB-to-UART port. Check that PORT matches the connected Windows COM port.
Close any other program using the serial port before retrying the flash command.
Help me troubleshoot the ESP32-S3 flash command.
Before you test the dashboard, predict how the mode card and activity log should react to each approved command. Then consider what should stay unchanged after unlock is rejected.
- Click Arm in the Approved Commands card.
You should see an accepted entry in the activity log. The mode card changes to armed after the status refresh.
- Click Standby in the Approved Commands card.
You should see another accepted entry. The mode card returns to standby.
- Type unlock into the custom command field.
- Click Send.
You should see a rejected activity-log entry containing HTTP 400. The mode card remains standby because the rejected value never updates current_mode.
Strong finish. The command boundary now changes state only for approved input.
✔️ Awesome, I've got everything!
Great. Double check that main/securelink.c is saved and that the dashboard accepts armed or standby while rejecting unlock.
ⓧ I'd like to double check the full code
Compare your completed main/securelink.c with this reference:
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include "esp_event.h"
#include "esp_http_server.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_timer.h"
#include "esp_wifi.h"
#include "nvs_flash.h"
#define SECURELINK_WIFI_SSID "SecureLink-Console"
#define SECURELINK_WIFI_PASSWORD "securelink-demo"
#define SECURELINK_WIFI_CHANNEL 1
#define SECURELINK_MAX_CONNECTIONS 4
static const char *TAG = "securelink";
static const char *current_mode = "standby";
extern const uint8_t index_html_start[] asm("_binary_index_html_start");
static void initialize_nvs(void)
{
esp_err_t result = nvs_flash_init();
if (result == ESP_ERR_NVS_NO_FREE_PAGES || result == ESP_ERR_NVS_NEW_VERSION_FOUND) {
ESP_ERROR_CHECK(nvs_flash_erase());
result = nvs_flash_init();
}
ESP_ERROR_CHECK(result);
}
static void start_softap(void)
{
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
esp_netif_t *ap_netif = esp_netif_create_default_wifi_ap();
wifi_init_config_t wifi_init_config = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&wifi_init_config));
wifi_config_t wifi_config = {
.ap = {
.ssid = SECURELINK_WIFI_SSID,
.ssid_len = strlen(SECURELINK_WIFI_SSID),
.channel = SECURELINK_WIFI_CHANNEL,
.password = SECURELINK_WIFI_PASSWORD,
.max_connection = SECURELINK_MAX_CONNECTIONS,
.authmode = WIFI_AUTH_WPA2_PSK,
.pmf_cfg = {
.required = true,
},
},
};
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_ERROR_CHECK(esp_netif_get_ip_info(ap_netif, &ip_info));
ESP_LOGI(TAG, "Wi-Fi ready: %s", SECURELINK_WIFI_SSID);
ESP_LOGI(TAG, "Open http://" IPSTR, IP2STR(&ip_info.ip));
}
static esp_err_t root_get_handler(httpd_req_t *request)
{
httpd_resp_set_type(request, "text/html");
return httpd_resp_send(
request,
(const char *)index_html_start,
HTTPD_RESP_USE_STRLEN
);
}
static esp_err_t status_get_handler(httpd_req_t *request)
{
char response[128];
int64_t uptime_seconds = esp_timer_get_time() / 1000000;
snprintf(
response,
sizeof(response),
"{\"device\":\"ESP32-S3\",\"mode\":\"%s\",\"uptime_seconds\":%" PRIi64 "}",
current_mode,
uptime_seconds
);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
static esp_err_t command_post_handler(httpd_req_t *request)
{
char command[32];
if (request->content_len == 0 || request->content_len >= sizeof(command)) {
ESP_LOGW(TAG, "Rejected command with invalid length: %u", (unsigned)request->content_len);
httpd_resp_set_status(request, HTTPD_400);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(
request,
"{\"accepted\":false,\"error\":\"command must be 1 to 31 bytes\"}",
HTTPD_RESP_USE_STRLEN
);
}
size_t offset = 0;
while (offset < request->content_len) {
int received = httpd_req_recv(
request,
command + offset,
request->content_len - offset
);
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
continue;
}
if (received <= 0) {
return ESP_FAIL;
}
offset += received;
}
command[offset] = '\0';
if (strcmp(command, "armed") == 0 || strcmp(command, "standby") == 0) {
current_mode = strcmp(command, "armed") == 0 ? "armed" : "standby";
char response[96];
snprintf(
response,
sizeof(response),
"{\"accepted\":true,\"mode\":\"%s\"}",
current_mode
);
ESP_LOGI(TAG, "Accepted command: %s", command);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(request, response, HTTPD_RESP_USE_STRLEN);
}
ESP_LOGW(TAG, "Rejected unsupported command");
httpd_resp_set_status(request, HTTPD_400);
httpd_resp_set_type(request, "application/json");
return httpd_resp_send(
request,
"{\"accepted\":false,\"error\":\"unsupported command\"}",
HTTPD_RESP_USE_STRLEN
);
}
static const httpd_uri_t root_uri = {
.uri = "/",
.method = HTTP_GET,
.handler = root_get_handler,
.user_ctx = NULL,
};
static const httpd_uri_t status_uri = {
.uri = "/api/status",
.method = HTTP_GET,
.handler = status_get_handler,
.user_ctx = NULL,
};
static const httpd_uri_t command_uri = {
.uri = "/api/command",
.method = HTTP_POST,
.handler = command_post_handler,
.user_ctx = NULL,
};
static httpd_handle_t start_webserver(void)
{
httpd_handle_t server = NULL;
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.lru_purge_enable = true;
if (httpd_start(&server, &config) != ESP_OK) {
ESP_LOGE(TAG, "Failed to start HTTP server");
return NULL;
}
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &root_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &status_uri));
ESP_ERROR_CHECK(httpd_register_uri_handler(server, &command_uri));
return server;
}
void app_main(void)
{
initialize_nvs();
start_softap();
if (start_webserver() == NULL) {
ESP_LOGE(TAG, "SecureLink startup failed");
return;
}
ESP_LOGI(TAG, "SecureLink command console is ready");
}
What should you compare?
Check the complete handler first. Then confirm that command_uri uses HTTP_POST and appears in the server's registration list.
Secret mission
Red-Team the Command Boundary
Your command endpoint accepts two exact values. Can mixed-case, whitespace-padded, unsupported, and oversized input bounce off without changing the live device mode? Finish with a valid command to prove the endpoint recovers normally.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your resources running, pause them for later, or remove them entirely. This project runs on local ESP32-S3 hardware with no cloud fees or ongoing service charges.
Resources you used:
- Completed securelink_console firmware stored in the ESP32-S3 flash.
- A powered SecureLink-Console SoftAP protected by WPA2.
- A local HTTP server providing the dashboard API while the board is powered.
- The root CMakeLists.txt project file on your Windows PC.
- The main/CMakeLists.txt component file on your Windows PC.
- The main/securelink.c firmware source file on your Windows PC.
- The main/index.html dashboard file on your Windows PC.
Keep everything running
No action is needed. Choose this option if you want the SecureLink dashboard available whenever the board has power.
- Leave the completed firmware in the ESP32-S3 flash.
- Keep the local project files so you can update the dashboard or firmware later.
- Power the board whenever you want SecureLink-Console available.
- Reconnect Windows to SecureLink-Console whenever you return to the command console.
Pause - I'll come back to this later
Disconnect the board to stop the local network without changing its flash. Your firmware remains ready for the next time you connect power.
- Disconnect the USB data cable from the ESP32-S3.
- Return to Microsoft Edge.
- Confirm the connection badge changes to Offline.
- Reconnect the USB data cable when you want to resume the project.
Your command console is safely paused. The firmware remains stored in flash for your next session.
Delete - I don't want to use this again
Erasing the board is permanent until you flash new firmware. Your local project files remain available until you remove them separately.
- Return to the IDF Terminal from your ESP-IDF installation.
- Stop the active monitor with Ctrl+] if the terminal is still streaming logs.
- Substitute the board's current COM port for PORT in the command below.
- Erase the firmware from the ESP32-S3 by running this command:
idf.py -p PORT erase-flash
What Does This Command Remove?
This command clears the flash on the selected ESP32-S3. The securelink_console firmware can no longer start from that board.
- Wait for the erase operation to complete.
- Check the available Windows Wi-Fi networks.
- Confirm SecureLink-Console is absent while the erased board remains powered.
That clears the board. SecureLink can no longer start from flash.
Erase Command Not Completing?
- Confirm PORT was replaced with the board's current COM port.
- Stop IDF Monitor with Ctrl+] if it still controls the serial connection.
- Reconnect the data-capable USB-to-UART cable if the terminal cannot communicate with the board.
Help me diagnose the ESP32-S3 erase failure.
- Use the Windows file browser to locate the project directory containing the root CMakeLists.txt file.
- Confirm that the file ends with project(securelink_console).
- Remove the project directory from its parent folder.
- Confirm the project directory is absent from its original location.
That is a clean slate. SecureLink is removed everywhere.
Nice Work!
Nice Work!
You did it! You built a browser command console that runs entirely on an ESP32-S3 and responds to real device state.
You've learned how to:
- Created a WPA2-protected SoftAP that serves a self-contained command dashboard without a router or cloud service.
- Connected the dashboard to GET /api/status so the live mode updates every two seconds. The same response keeps the displayed uptime current.
- Protected POST /api/command with a deny-by-default command boundary. Exact allow-list matching returns an HTTP 400 response for unsupported input while preserving current_mode.
- Completed the Secret Mission by red-teaming the endpoint with mixed-case input. You repeated the test with whitespace-padded commands plus unsupported commands plus oversized commands. Every rejection preserved device mode before a valid command confirmed normal recovery.
Ready to quiz yourself?